Stable orientation for FoxSchema. For where a change belongs see CODE_MAP.md. Workflow engine runbook: workflow.md.
Database schema diff & migration tool. Compare a source schema against a target,
generate dialect-native migration SQL, and deploy it. Fourteen SQL dialects:
Postgres (and CockroachDB / YugabyteDB / Redshift), MySQL (and MariaDB / TiDB),
SQL Server / Azure SQL, Oracle, Db2, SQLite, DuckDB, ClickHouse. MongoDB and Redis
carry connection settings only. Distributions: CLI (foxschema via npm/Homebrew)
and Docker (single amd64 image with Db2).
# CLI-first (after npm i -g foxschema, or from the monorepo build)
foxschema # start UI on :3210 + open browser
foxschema stop
foxschema doctor
# Development (starts both the Fastify API + Vite frontend)
npm run dev # sign-in required; first open runs setup
npm run dev:auth # multi-user auth mode
npm run dev:with-workflow # plus workflow-server on :8081
# Typecheck — primary correctness gate
cd apps/web && npx tsc --noEmit
# Tests — run from repo root (covers packages/* and apps/web/**/*.test.ts)
npx vitest run # all tests
npx vitest run packages/sql # dialect-layer tests only
npx vitest run packages/db # driver/runtime tests only
npx vitest run --reporter=verbose # with test names
npx vitest <pattern> # e.g. npx vitest sql-generator
# CLI (development)
cd apps/cli && npm run foxschema -- doctor
cd apps/cli && npm run foxschema -- compare --source ... --target ...Backend changes (packages/server, packages/sql, packages/db) hot-reload via tsx watch.
Playwright + Vitest against the Docker dialect containers. See
testing/RUNNING_E2E.md for running it (reseed before every run,
restart the API before reseeding Oracle/Db2, failures that come from the machine) and
docs/plans/2026-07-01-seed-test-matrix.md for the seed matrix.
# One-command reset: compose up + wait healthy + restart dev + reseed
bash scripts/seed/reset-all.sh
bash scripts/seed/seed-all.sh all # reseed only
# Run the suite (dev server must be up)
cd apps/e2e && node scripts/run-all.mjsnpm workspaces — not pnpm or Turborepo.
packages/sql/ @foxschema/sql — dialect knowledge (pure, browser-safe)
src/interfaces/ TableSchema, TableDiff, ColumnDiff, MigrationStep, etc.
src/modules/ One folder per domain: dialect, sql-text, schema-diff,
migrations, lokee-weave, sql-editor, access, utilities
src/providers/ 14 SQL dialects, each with settings + sql-dialect
(+ optional *.user-sql.ts for account DDL,
*.backup.ts for backup/restore commands)
(MongoDB and Redis carry settings only; they still
have backup command builders)
src/cores/ Connection strings, catalog rows → TableSchema,
connection auth (password / Windows NTLM / Db2 LDAP)
packages/ui-shared/ @foxschema/ui-shared — the part of @foxschema/sql the
web app may use: a named, by-reference re-export. The
frontend imports this, never @foxschema/sql directly
packages/db/ @foxschema/db — Node runtime: drivers, pooling, execution
src/providers/ One adapter and provider per dialect
src/cores/ ConnectionFactory, pooling, circuit breaker
packages/shared/ Contracts the frontend, server and CLI agree on:
permission names, error codes, wire message shapes
packages/server/ The backend: Fastify HTTP layer, feature modules,
metadata store
packages/workflow-contract Browser-safe engine types and token/route names
packages/workflow-engine Runtime, SQLite stores, built-in pipes (Node)
apps/web/src/frontend/
app/ Application shell, settings screens, view registry
features/ One folder per business domain
platform/ App-wide state: auth, toasts, appearance, navigation
shared/ API client, UI components, lib, utils
apps/cli/ `foxschema` CLI — browser launcher (:3210), line commands, Ink TUI
apps/workflow-server/ Workflow engine HTTP process (:8081); optional scheduler/worker
apps/e2e/ Playwright tests that drive the running application
packaging/homebrew/ Scripts to refresh Formula/foxschema.rb (Homebrew, same repo)
docs/CODE_MAP.md says what each of those folders covers, and where a
change belongs.
Formula/ Homebrew formula (tap this GitHub repo directly)
The server and the web app are each a modular monolith: product code lives in one folder per feature, composed in one static, typed registry per side.
Server (packages/server/src) |
Web (apps/web/src/frontend) |
|
|---|---|---|
| A feature | features/<id>/, whose index.ts exports a ServerFeatureModule (its mounts and their access level) |
features/<id>/, whose root files are its public API: a light index.ts, plus view.ts, toolbar.ts, ui.ts where needed |
| Registered in | app/feature-registry.ts |
platform/viewIds.ts and app/features/featureRegistry.ts |
| Shared capabilities | platform/: identity, authorization, settings, connections, workspaces, http |
platform/: auth, toasts, appearance, navigation; shared/: api client, ui components, lib |
| Enforced by | architecture.test.ts: layers import downward only; features meet only through index.ts; no cycles |
architecture.test.ts: layers import downward only; outside a feature only its root files; no cycle across a feature boundary |
npm run feature:new -- <id> scaffolds both sides and registers them. The HTTP contract test
compares its table with what the registry serves, so a new route is listed with what it
answers. The conventions are in
docs/architecture/FEATURE-MODULE-GUIDE.md, the import
rules in docs/architecture/FEATURE-DEPENDENCY-RULES.md.
Every persisted product row is workspace-scoped (connections, app_secrets,
cloud_provider_credentials, lokee_databases, migration_runs,
data_migrate_runs). Git repositories and install settings are install-wide.
Workflow engine settings still use COMMUNITY_WORKSPACE_ID (local); a
connection grant follows the connection’s workspace.
- Personal workspace. Created with the account (
ensurePersonalWorkspace). Its member role mirrors the account role (personalRoleFor: admin → owner). Always private; cannot be archived. The account it belongs to always manages its members. - Request scope.
authGuardcallsresolveWorkspace.X-Fox-Workspacenames one the caller is a member of, else 404 (never 403 — that would confirm a private id). No header:user_preferences.last_workspace_id, else the personal workspace. The UI does not send the header; switching isPOST /api/workspaces/:id/select, then a reload. UsescopeOf(req)/WorkspaceScopein services — never a bareuser_idfilter. - Permissions.
effectivePermissions(account, workspace): install keys (INSTALL_PERMISSIONS:editor.advanced, Git, workflow,admin.*,workspace.create,app.write,account.self) come from the account role; everything else from the member’s role in this workspace. Neither side lends the other its keys. An admin who is a member keeps every permission. An account withoutapp.writemay call only GET, or a route that declaresreads(platform/http/router.ts);authGuardrefuses the rest before workspace scope. The Demo role is the built-in role without it. - Role ceiling. A personal workspace lends no more than its account holds
(
workspaceRoleCeiling/cappedRole): a viewer account invites viewers only. A stored membership above the ceiling still acts at the ceiling. A demo login is a viewer in every workspace (memberRoleCeiling). Shared workspace owners can still grant owner. The grant UI offers onlyworkspaceGrantCeiling. - Admin list.
GET /api/workspaces/allis names, owners and sizes — never the workspace’s data. Opening it is joining (AdminWorkspacesPanel). - Public join. Anyone signed in may join a public non-personal workspace
(
GET /api/workspaces/discover,POST /:id/join). Join role is viewer or editor, never owner. An invite never adds: the invitee accepts (POST /api/workspaces/invites/:id/accept, 14-day TTL). - UI. One workspace is open.
WorkspaceRailButtonsits under the Fox mark on the activity rail (not the profile menu) and opens the sameWorkspaceMenu(switch, invites, settings, public join, create). A launch session hides it. Switching is stillPOST /api/workspaces/:id/selectthen reload. Editor tabs/scripts in this browser are keyed by workspace id (editorWorkspaceStorage.ts); the unscoped blob is claimed once by the first workspace the rail sees.
Startup backfillWorkspaces gives each account a personal workspace and moves
rows that still have workspace_id null or 'local' into their author’s.
Saved credentials keep ConnectionOptions (including optional authMethod
and domain) inside encrypted_config. Methods:
password(default) — SQL / native username + password.windows— SQL Server / Azure NTLM. The adapter builds tediousauthentication.type = 'ntlm'from domain + user + Windows password. Do not emitAuthentication=Active Directory Integrated(that is AAD).ldap— Db2 directory user. Still UID/PWD; LDAP is server-side (Authentication=SERVER_ENCRYPTwith the existing SERVER retry).none— Redis or MongoDB with no auth. Nothing is sent or asked for. A Redis that only hasrequirepassstill needspasswordeven without a username; saved without its secret, that connection is prompted when you run.
SavedConnectionSummary exposes authMethod / domain / hasPassword but
never the secret. Windows integrated SSO (no password) is not implemented.
The frontend imports browser-safe workspace packages through Vite aliases
(@foxschema/ui-shared, @foxschema/shared, @foxschema/workflow-contract,
@foxschema/workflow-engine/definitions). Facades live in
apps/web/src/frontend/shared/lib/. @foxschema/db and @foxschema/server
are not aliased — a UI import of either fails the build.
Dialect code reaches the browser only through @foxschema/ui-shared, which
names each @foxschema/sql export the frontend uses and re-exports it by
reference. Importing @foxschema/sql from the frontend fails
apps/web/src/frontend/architecture.test.ts. To use another part of the engine
in the browser, add it to packages/ui-shared/src/index.ts, where the change
shows up in review. ui-shared.test.ts checks that every name there is the
@foxschema/sql export itself, so the browser and the server cannot drift. The
bundle is unchanged: re-exports add no code.
shared/lib/provider-settings.ts used to be a real copy of the dialect registry,
kept on the theory that the browser should not pull the driver runtime in. It does
not: @foxschema/sql is dependency-free and Node-free by design, enforced by
purity.test.ts. The copy drifted on postgres.schemaRequired and broke schema
browsing for schema-less PostgreSQL connections, so it is a facade now, and
provider-settings-facade.test.ts asserts its exports are the same objects as
core's.
- Compare (server-side):
POST /api/compare→CompareModule.compare()→TableDiff[] - Generate (client-side):
SqlGeneratorModule.generateMigrationPlan(diffs, targetDialect, mapping)→MigrationStep[]. Runs in the browser on every selection toggle — no round-trip. - Execute (server-side):
POST /api/migration/execute→MigrationModulestreamsMigrationEventobjects via SSE back toMigrationProgressPanel.
The Compare Execute button is gated in the browser first
(features/compare/lib/executeGate.ts). A missing foreign-key target or an
unacknowledged narrowing change keeps the click and opens that review
(executeClick) instead of deploying. Drop-dependency conflicts, unacked
destructive drops / MySQL binlog risk, a required Git commit, and a disconnected
target disable the button.
SchemaMapping threads through generation: sourceSchema, targetSchema, sourceDialect,
targetDialect, nonDestructive, targetServerVersion.
Each SQL dialect has three layers, split across packages/sql/src/providers/
(dialect + settings) and packages/db/src/providers/ (adapter + provider).
Wire-compatible relatives share a family (dialectFamily() in
packages/sql/src/providers/provider-settings.ts): MariaDB / TiDB → mysql;
CockroachDB / YugabyteDB / Redshift → postgres; Azure SQL → sqlserver. Use
that helper instead of listing dialects by hand (Format / Monaco language, access
SQL, file-import batch size). packages/sql lists 14 SQL dialects (MongoDB and
Redis carry settings only):
| File | Interface | Registry |
|---|---|---|
<d>.settings.ts |
ProviderConnectionSettings |
provider-settings.ts |
<d>.adapter.ts |
DriverAdapter |
adapter-registry.ts |
<d>.provider.ts |
SchemaProvider |
provider-registry.ts |
Plus <d>.sql-dialect.ts implementing SqlDialect — registered in modules/dialect/registry.ts.
Account DDL (CREATE/ALTER/DROP USER|ROLE) is a sibling strategy, not on SqlDialect:
<d>.user-sql.ts implementing UserSqlDialect, registered in
modules/access/user-sql.registry.ts. Aliases re-export (e.g. Azure→SQL Server,
TiDB→MySQL); Redshift has its own module (GROUP, not ROLE). Db2 has no CREATE USER
(canCreateUser: false); buildDb2OsUserInstructions emits copy-paste docker +
GRANT CONNECT steps for the foxschema-db2 container instead. GRANT/REVOKE is the
same pattern: <d>.access-sql.ts / modules/access/access-sql.registry.ts
(Redshift reuses Postgres GRANT; account DDL stays separate). Both stay in
@foxschema/sql so the browser Access Assistant can generate SQL (through
@foxschema/ui-shared) — do not put these emitters in @foxschema/db (Node
drivers only).
The SqlDialect interface has optional hooks; the generator uses a generic fallback when a
hook is absent. Adding dialect-specific behavior = implement the hook in that dialect's file
only. Key hooks: dropForeignKeyStatement, dropIndexStatement, dropTriggerStatement,
createTriggerStatement, preDropTableStatements, createViewStatement, alterViewStatement,
wrapCreateSequence, dropTableStatement, dropViewStatement, dropSequenceStatement,
dropFunctionStatement, dropProcedureStatement. Full hook map + fallback behavior +
per-dialect gotchas live in packages/sql/src/providers/DIALECTS.md (tracked).
Safe column change is a sibling registry, not a SqlDialect hook:
planColumnChange (modules/utilities/column-change.ts) finds impact and
blockers from the schema Fox already loaded; each family writes steps in
providers/<d>/*.column-change.ts (column-change.registry.ts). Engines not
listed answer "not yet" rather than SQL that would fail there. The Utils panel
computes the plan in the browser (@foxschema/ui-shared); Fox runs none of it.
A rename alone is in-place; a type change adds, copies in batches, switches
over, and drops last. PostgreSQL / YugabyteDB / CockroachDB, MySQL / MariaDB /
TiDB, and SQL Server / Azure SQL are in v1. SQLite needs a table rebuild;
Redshift's ALTER TABLE cannot express the steps.
Version-aware DDL: SchemaProvider.detectVersion?() → stored in Zustand as
targetServerVersion → flows into SchemaMapping → dialect drop hooks use it. Oracle pre-23c
uses PL/SQL exception blocks; DB2 (all versions) uses SQL PL CONTINUE HANDLER FOR SQLSTATE '42704'.
The Compare store (Zustand) lives in features/compare/state/, split across three files:
syncTypes.ts—SyncStateinterface,MigrationProgressItem,ConnectionConfigsyncHelpers.ts—buildRef,buildMapping,buildIncludedDiffs,regenerateSql, sharedsqlGeneratorModuleinstanceuseSyncStore.ts— the store implementation
Other features read it through @/features/compare. The SQL editor store is
features/sql-editor/state/useSqlEditorStore.ts, read through @/features/sql-editor/state.
regenerateSql is called on every selection toggle; it runs SqlGeneratorModule
synchronously in the browser. applyMigration sends the full MigrationStep[] plan to the
backend and streams results back via SSE.
A first visit downloads index.html and what it names: about 144 KB gzip (120 KB Brotli)
since 2026-10-10, down from 398 KB before 2026-10-06. CI keeps it under 170 KB gzip
(npm run bundle:first-load after npm run build -w @foxschema/web); the report lists the
largest files when it fails. Everything else loads when it is used:
- Views load from the view registry (
app/features/featureRegistry.ts), each from its feature'sview.ts. App.tsx renders them withlazy(), and the activity rail callsprefetchViewon hover or focus, so a view's code is usually already loaded by the time the click lands. - Panels that open on a click (admin console, credentials, applies history, new
connection, the workspace menu) are
lazy()insideMountWhenOpened. That mounts a panel on its first open and keeps it mounted afterwards, so its state survives closing exactly as before. The workspace button is shell, not a view — it is not in the view registry. - Heavy libraries (sql-formatter, Prettier, faker, lodash, date-fns, zod, ajv) load
through
shared/lib/loadOnce.ts: one shared promise, retried after a failure. A component that needs one while rendering usesuseLoaded(loader), which re-renders once when the library arrives.useSqlFormatis the example: DDL shows unformatted for that first moment, then formatted. - A feature's
index.tsstays light, because the first screen imports it: a module re-exported there is downloaded by everyone who imports any of it (Rolldown does not drop unused re-exports of modules with top-level calls; oneindex.tsimport from the toolbar once cost 54 KB gzip). Heavier pieces get their own root entry:ui.tsfor components other features compose,state.tsfor the SQL editor store,view.tsfor the workspace,toolbar.tsfor what a view adds to the top toolbar. Lazy components areload…functions inindex.ts, never re-exports. - The code-cell worker is an ES module worker (
worker.format: 'es'). The default IIFE format inlined faker, lodash and date-fns into the worker. - TypeScript code cells compile on the server (
POST /api/sql/code-cell/transpile, the sametranspileTsNode cells use) and run in the browser. The browser no longer downloads the TypeScript compiler (3.4 MB). - Stores stay out of the first page. The shell reads recent queries from
features/sql-editor/state/recentQueries.ts(through@/features/sql-editor), a small copy that follows the SQL editor store once it loads; Home and the command palette load that store on a click. The sync store loads the migration generator with the first browse or compare (loadSqlGenerator), andsqlGenerator()throws if a path uses it earlier, rather than returning an empty script. The Compare button asksschemaCompareBlockerfrompackages/sql/src/modules/capabilities/schema-compare.ts, which reads the dialect key list (SQL_DIALECT_KEYS, checked againstDIALECT_MAPbysatisfies) instead of loading every dialect. - Sign-in, onboarding and the signup offer are lazy: a signed-in first page does not carry them. Startup asks for the setup state and the session at once, and starts loading the view the reader last had open while it does.
- React has its own chunk (
vendor-react), so it stays cached across releases while the app's entry chunk changes name with every app change.
The server sends the build pre-compressed. src/build/precompress.ts writes .br and
.gz copies at build time, and packages/server/src/api/static-assets.ts serves them:
Brotli, then gzip, then the file itself. Hashed /assets/* files are cached for a year
(immutable); index.html and other paths get no-cache. A missing /assets/* file is a
404, not index.html. A tab left open across a release then hits vite:preloadError,
and main.tsx reloads it once. The CLI package leaves the compressed copies out
(loopback gains nothing from them).
Rendering on the hot paths (what to keep when editing these components):
- The SQL editor store saves through
shared/lib/deferredLocalStorage.ts: 400 ms after the last change and at once onpagehide/ hidden, not on every keystroke. Code that reads the saved copy in the same page dispatchespagehidefirst (the e2eSqlEditorPagedoes). - The migration plan is cached in the sync store on the identity of its inputs
(
cachedPlan), so Execute, the Git commit check and the review notes share one build. - The Compare tree rows, the permission matrix rows and the results panel are
React.memoand are passed stable props; an inline callback or a default[]prop breaks that. The run rail caches each statement's Safe-mode badge by its text.
packages/sql/src/modules/sql-text/ is the lexical foundation. Permission
gates, data-migrate, the single-statement check and the editor strip must see
SQL the way the destination database will.
| File | Job |
|---|---|
sql-tokens.ts |
One tokenizer. Tokens cover the text exactly. Options are the engines’ disagreements (backslash escapes, # comments, dollar quotes, nested comments, /*! executable comments, ANSI_QUOTES, …). |
sql-readings.ts |
Which option combinations each dialect can use. A session setting that we have not ruled out is a second reading. No dialect, or an unknown one, gets every reading. canonicalSql writes a form only that reading fits, so later scanners cannot disagree. |
sql-splitter.ts |
Splits the editor buffer (splitSqlStatements, including -- @js / -- @node fences). Classifiers (sqlStatementCategories, isWriteStatement, requiresWritePermission, statementVerb, sqlStatementCount) take the dialect and ask every reading of it (statementsAsSent). |
Constraints:
- Do not write a second scanner. Keyword-scan strippers, migration-file
scrubbers and gates should read
sqlTokens/canonicalSql. - Pass the dialect. The same text is a MySQL comment (
SELECT 1 # 2 INTO t) and a PostgreSQLSELECT … INTO(DDL). Without a dialect the gate assumes every reading. - Code fences that reach the database are comments. The editor runs a closed
-- @node…-- @endcell itself and never posts it to/sql/execute. A fence in text that does reach the database is two--lines around SQL, and the SQL is priced like any other (-- @node\nDROP TABLE t\n-- @endneedseditor.ddl). - Adding a dialect: say how it lexes in
sql-readings.ts(seepackages/sql/src/providers/DIALECTS.mdstep 5). Missing there is safe but slow (every reading of every dialect).
- Create the dialect files in
packages/sql/src/providers/<name>/and the driver files inpackages/db/src/providers/<name>/ - Register in
provider-settings.ts,adapter-registry.ts,provider-registry.ts,modules/dialect/registry.ts(andmodules/access/user-sql.registry.ts/modules/access/access-sql.registry.tswhen the engine has account or GRANT SQL). Backup/restore commands:<d>.backup.tsinmodules/utilities/backup.registry.ts. Ahistoryquery, where the engine records its backups, lets the panel list them; a server-side SQL backup can be run from the panel, a restore never is. Safe column change:<d>.column-change.tsinmodules/utilities/column-change.registry.ts. Omit the engine to answer "not yet"; do not guess SQL. SQLite is a table rebuild (later); Redshift is not in v1. - Add the dialect name to the
Dialectunion and theDIALECTSarray inpackages/sql/src/providers/provider-settings.ts(dialect-registry.test.tsfails until they matchPROVIDER_SETTINGS). Nothing inapps/webneeds editing — the frontend re-exports this registry. If the adapter'sbeginTransaction/rollbackTransactiondo nothing, settransactions: falsein<name>.settings.ts: the editor then refuses One transaction there instead of reporting a rollback that never happened, Data migrate's Transaction checkbox is off and disabled, and@foxschema/dbreports neither DML nor DDL rollback. Say why in thetransactionsentry ofmodules/capabilities/dialect-features.ts— it is the checkbox's tooltip, anddialect-features.test.tsfails until each such engine has one. - Add
parseType/renderTyperound-trip tests intype-mapping.test.ts - Verify each optional hook against real DDL — the generic fallbacks are often wrong for DROP INDEX/TRIGGER/FK
- Say how the engine lexes SQL in
modules/sql-text/sql-readings.ts(comments, quotes, escapes, and every reading a server setting can switch). A dialect missing there gets every reading of every dialect. npx vitest run+cd apps/web && npx tsc --noEmit