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/sdkdependency (package.json)- The Directus
projectscollection already exists and already holds the migrated contents of data/projects.json — confirmed by the user. Primary key fieldid(string, matchesMapProject.id), JSON fielddata(holds theMapProjectpayload). This repo has nodirectus/schema/*.jsonsnapshot to verify this against, so it is trusted as stated rather than grounded in a file (see Risks). directusAsUser/toDirectusFailurein 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.tstoday (home,create,project/:projectId,project/:projectId/map/new,project/:projectId/map/:mapId) is already nested underroutes/protected-layout.tsxin 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:
- app/lib/env.server.ts — new
PROJECTS_STORAGEvariable - .env.example — document it
- app/lib/session.server.ts — a
request-scoped context carrying the resolved
ActiveSession, populated byrequireSessionMiddleware - app/lib/projects.server.ts — the
entire storage layer: every exported function gains a leading
accessToken: stringparameter and becomesasync - Every caller of
projects.server.ts: app/routes/home.tsx, app/routes/create.tsx, app/routes/add-map.tsx, app/routes/project.tsx, app/routes/project-map.tsx
Out:
- Creating/altering the Directus collection or its fields
- Migrating
data/projects.jsoncontent 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.jsonor the local-file code path — it remains the default, chosen byPROJECTS_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 fromprojects.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.tsexportsPROJECTS_STORAGE: "local" | "directus", read fromprocess.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 joinREQUIRED).- .env.example documents
PROJECTS_STORAGEwith a comment naming the two accepted values and the default. - Nothing else in
env.server.tschanges. pnpm typecheckpasses (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
activeSessionContextis created with React Router'screateContext<ActiveSession>()(imported from"react-router", already a dependency — no new package). requireSessionMiddlewarecallscontext.set(activeSessionContext, active)afterrequireSession(request)succeeds and before callingnext(). Its existingsetCookiehandling on the response is unchanged.- A new
activeAccessToken(context: RouterContextProvider): stringhelper readscontext.get(activeSessionContext).values.accessToken— this is the only shape nested routes need, so they don't have to know aboutActiveSession's other fields. - No existing export's signature changes (
getActiveSession,requireSession,requireSessionForResource,sanitizeReturnTo, etc. are untouched) — this task only adds. pnpm typecheckpasses (nothing yet callsactiveAccessToken).
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/ProjectsSchematypes are added, matching the Data model above.getProjectRow,writeProjectRow,createProjectRow,listProjectRows,normalizeProjectare added as new internal (non-exported)asyncfunctions. Each branches onenv.PROJECTS_STORAGE:"local": same whole-file read/write as today'sreadProjectsFile/writeProjectsFile(which stay as-is and are called from inside these new primitives — not deleted)."directus":directusAsUser(accessToken).request(...)withreadItem/readItems/createItem/updateItemfrom@directus/sdk, each called with an explicitProjectsSchemageneric. AreadItem/readItemsfailure is caught, logged viatoDirectusFailure, and surfaces asundefined/[]the same way a missing row does — no unhandled rejection reaches a caller.normalizeProjectcontains exactly the defaulting logicreadProjectsFileapplies today (poiTypes/lineTypes/areaTypesfallback to theDEFAULT_*constants;pois/lines/areas/imageOverlaysdefault to[]), applied to a singleMapProjectinstead of every project in a file.- None of the ~30 existing exported functions, and none of their callers, change in this task.
pnpm typecheckpasses.
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,updateProjectSettingsare converted to the async,accessToken-first signatures in the Data model above, implemented vialistProjectRows/getProjectRow/createProjectRow/writeProjectRow+normalizeProject. Their existing business logic (id generation,undefinedon missing project) is unchanged.readProjectsFile/writeProjectsFileare 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
accessTokenviaactiveAccessToken(context): - app/routes/home.tsx —
loaderbecomesasync, awaitslistProjects. - app/routes/create.tsx —
actionawaitscreateProject. - app/routes/add-map.tsx —
loaderbecomesasync, awaitsgetProject. - app/routes/project.tsx —
loaderbecomesasync, awaitsgetProject; the settings branch ofactionawaitsupdateProjectSettings. The poi-type/line-type/area-type branches ofactionare untouched in this task (T7). - app/routes/project-map.tsx —
loaderbecomesasync, awaitsgetProject.actionis untouched in this task (T5/T6). pnpm typecheckpasses.
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,removePointOfInterestare converted to the async,accessToken-first signatures.- Callers updated in the same task:
- app/routes/add-map.tsx —
actionawaitsaddMap. - app/routes/project-map.tsx —
the
"remove","move","add","update"intent branches ofactionawait their respective functions. The line/area/type intent branches are untouched in this task. pnpm typecheckpasses.
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,removeAreaare 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 ofactionawait their respective functions. pnpm typecheckpasses.
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,removeAreaTypeare 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.tsis still synchronous or missing theaccessTokenparameter after this task. readProjectsFile/writeProjectsFilenow have exactly one remaining caller each: the"local"branch inside the T3 primitives. They stay — not exported, not deleted.pnpm typecheckpasses.
T8 — Final verification¶
Goal: Both storage backends work end to end.
Acceptance criteria:
pnpm typecheckandpnpm buildpass with no errors anywhere in the repo.pnpm formatproduces no diff.- Manual check,
PROJECTS_STORAGEunset (orlocal):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'sprojectscollection (the row'sdatafield), and that reloading the page (a freshGET) 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 underPROJECTS_STORAGE=directusstill succeeds — i.e. the token refresh inrequireSessionMiddlewareis not duplicated by any nested loader (the T2 risk this task is designed to avoid).
Risks / open questions¶
- The Directus
projectscollection's field names (id,data) and its existing data are taken on the user's word — this repo has nodirectus/schema/*.jsonsnapshot 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 generictoDirectusFailuremessage with no pointer to "check Directus permissions," which could read as a bug during manual testing in T8.