Development setup
Layout
Section titled “Layout”apps/ api/ Express + Drizzle. The whole backend. drizzle/ SQL migrations + snapshots src/ db/ schema.ts, postgres.ts lib/ gemini.ts, semantic-extraction.ts, password.ts middleware/ auth.ts (API key), session.ts, validate.ts routes/ one router per resource, thin, HTTP only services/ the logic main.ts wiring, first-boot seed, background jobs dashboard/ Vite + React 19 + React Routerpackages/ sdk/ @memory-soda/sdk, published types/ shared types, type-only at runtimedeveloper-docs/ this documentationWhere logic lives
Section titled “Where logic lives”Routes are thin. Parse, validate with zod, call a service, map the result to HTTP. No business logic.
Services own everything else, database access, LLM calls, transactions. They throw plain errors; routes translate them to status codes.
packages/types is type-only at runtime. The API cannot import a runtime
value from it. That is why ENTITY_TYPES is duplicated as a const in
semantic-extraction.ts, the type union lives in packages/types, the runtime
allow-list has to be local.
Commands
Section titled “Commands”npm run dev # API + dashboard, watch modenpm run build # everythingnpm run typecheck # all 5 projectsnpm run test # currently the api project onlynpm run lint
npm run sdk:build # build just the SDK
# from apps/apinpm run --workspace=apps/api db:generate # create a migration from schema.tsnpm run --workspace=apps/api db:migrate # apply pending migrationsnpm run --workspace=apps/api db:studio # schema/data browserNx caches aggressively. --skip-nx-cache forces a rerun when you suspect a stale
result.
Working on the API
Section titled “Working on the API”The dev server restarts on save. Watch its log, extraction happens in the background and only surfaces there.
tail -f /tmp/api.log | grep -E '\[semantic\]|\[episodic\]|\[recall\]'Making extraction fast to iterate on
Section titled “Making extraction fast to iterate on”The default 30-minute inactivity timer makes each cycle slow. Either call
endThread() or create test threads with a low override:
await memory.createThread({ dataset: 'dev_scratch', settings: { episodic: { autoEpisodeIntervalMs: 1000 } },});Or force it: POST /v1/threads/:id/end.
The Playground is the fastest loop, it shows every call, polls for extraction, and displays the facts that came out.
Inspecting state
Section titled “Inspecting state”psql "$DATABASE_URL"-- pipeline healthSELECT status, semantic_status, count(*) FROM episodes GROUP BY 1,2;
-- what got extracted from an episodeSELECT subject, predicate, object, confidence, source_quoteFROM facts WHERE episode_id = '…';
-- reset a dataset without dropping the databaseDELETE FROM threads WHERE dataset = 'dev_scratch';DELETE FROM facts WHERE dataset = 'dev_scratch';DELETE FROM entities WHERE dataset = 'dev_scratch';DELETE FROM episodes WHERE dataset = 'dev_scratch';Delete facts before episodes, facts.episode_id is ON DELETE SET NULL, so
the reverse order orphans them.
Working on the SDK
Section titled “Working on the SDK”The SDK is a thin typed wrapper over fetch. Adding a method means:
- Add or update the type in
packages/types/src/lib/. - Export it from
packages/sdk/src/index.ts. - Add the method with a JSDoc block, the docs are generated from reading these.
- Update the SDK reference.
Testing against a local app:
npm run sdk:buildnpm link --workspace=packages/sdk# in your appnpm link @memory-soda/sdkAfter changing SDK source, npm run sdk:build is enough, the link picks up the
new dist.
Keep it dependency-free. The SDK has zero runtime dependencies and that is worth preserving.
Working on the dashboard
Section titled “Working on the dashboard”npx nx dev dashboardVite on :3000, proxying nothing, it calls the API at VITE_API_URL directly,
so CORS_ORIGIN on the API must match.
UI components live in apps/dashboard/src/components/ui/, shadcn-style wrappers
over Base UI. Check there before hand-rolling a component. A dropdown, dialog,
select, tooltip, table and sheet all already exist.
Base UI components have structural requirements.
DropdownMenuLabelthrows unless it is inside aDropdownMenuGroup, for instance. Read the component before using it.
Conventions
Section titled “Conventions”Comments explain why, not what. The codebase is dense with rationale
comments on non-obvious decisions, why thinking is disabled on structured
calls, why relationships are demoted rather than dropped, why the correlated
subquery qualifies threads.id. Match that.
Prefer deleting. The surface is already larger than it should be.
Match surrounding style. No linter enforces most of it; read the neighbours.
Gotchas
Section titled “Gotchas”Drizzle renders interpolated columns unqualified. In a correlated subquery a
bare "id" binds to the inner table:
// wrong, "id" resolves to messages.id, so the predicate is never truesql`(select count(*)::int from ${messages} where ${messages.threadId} = ${threads.id})`;
// rightsql`(select count(*)::int from ${messages} where ${messages.threadId} = ${threads}."id")`;Drizzle wraps driver errors. The pg code is on .cause, not the thrown
error. Use isUniqueViolation() from db/postgres.ts.
Zod strips unknown fields, it does not reject them. A renamed field silently loses data rather than returning 400.
db:push desynchronises the migration journal. Never run it against a
database you care about.
The API will not boot without GOOGLE_GENERATIVE_AI_API_KEY. The module
throws at import.
Migrations that rename need writing by hand. drizzle-kit generate prompts
interactively and cannot be automated. See
Migrations.
Before opening a PR
Section titled “Before opening a PR”npm run typechecknpm run buildnpm run testAll three must pass. If you touched the extraction pipeline, retrieval or compaction, exercise it end to end, typecheck will not catch a query that returns the wrong rows. See Testing.