This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
When providing SQL commands, write them as plain text WITHOUT code block formatting (no triple backticks). This avoids invisible character issues when copying from the terminal.
Example - do this: ALTER TABLE users ADD COLUMN email VARCHAR(255)
Not this:
ALTER TABLE users ADD COLUMN email VARCHAR(255)This is AccessClone, a platform for converting MS Access databases to web applications with multi-database support using PostgreSQL.
- Frontend: ClojureScript/Reagent (ui/src/app/)
- Backend: Node.js/Express (server/)
- Database: PostgreSQL with schema-per-database isolation
- Desktop: Electron wrapper (electron/)
database_idis a text slug everywhere (e.g.'northwind2','calculator'), NOT an integer- Generated by slugifying the database name: lowercase, non-alphanumeric →
_ - The
shared.databasestable is the source of truth:database_id text PRIMARY KEY shared.objectsis the unified table for all application objects (forms, reports, modules, macros) with atypediscriminator columnshared.intentsstores intent data separately with FK toshared.objects.id- Legacy tables (
shared.forms,shared.reports,shared.modules,shared.macros) still exist but are no longer used by application code - The frontend sends the text slug in the
X-Database-IDheader - When querying manually:
WHERE database_id = 'northwind2'(not= 11) - Module/macro data stored in
definitionJSONB: access viadefinition->>'vba_source'(text) ordefinition->'js_handlers'(JSONB)
- Design View: Visual drag-drop editor
- Form View: Live data entry with record navigation
- Property Sheet: Access-style tabbed interface (Format/Data/Event/Other/All)
- Controls bind to database fields via
:field(drag-drop) or:control-source(Property Sheet) - Continuous Forms:
:default-view "Continuous Forms"renders header once, detail per record, footer once - New records marked with
:__new__ trueto distinguish INSERT from UPDATE - Close button in forms calls
close-tab!to close current tab - Popup Forms:
:popup 1renders as floating window;:modal 1adds full-screen backdrop (z-index 900) normalize-form-definitionin state_form.cljs normalizes form data on load: coerces control:typeto keyword, yes/no props to 0/1 integers (with defaults), and numeric props to numbers — across form-level and all sections- Access &-hotkey rendering: Captions with
&markers (e.g."Product &Vendors") render the hotkey letter underlined viarender-hotkey-textineditor_utils.cljs.display-textreturns hiccup (not plain strings) so labels/buttons/tabs show underlined hotkeys in both forms and reports.strip-access-hotkey(public) returns plain text for action matching. Alt+letter keyboard handler inform_view.cljsactivates the matching control.
- Reports are banded (unlike forms which have 3 fixed sections: header/detail/footer). A banded report has 5 standard bands plus dynamic group bands that repeat based on data grouping:
- report-header (once), page-header (each page), group-header-0..N (on group break), detail (each record), group-footer-N..0 (on group break), page-footer (each page), report-footer (once)
- Design View: Visual drag-drop editor with all bands rendered as resizable sections
- Preview: Read-only page layout with live data, group break detection, banded section rendering
- Property Sheet: Access-style tabbed interface (Format/Data/Event/Other/All) for report-level, section (band), group-level, and control properties
- Group-level properties (field, sort-order, group-on, group-interval, keep-together) stored in
:groupingarray, edited via Data tab when a group band is selected normalize-report-definitionin state_report.cljs normalizes report data on load (same pattern as forms)- Files: report_editor.cljs (orchestrator), report_design.cljs (design surface), report_properties.cljs (property sheet), report_view.cljs (preview), report_utils.cljs (utilities)
- Datasheet View: Editable grid with inline cell editing
- Design View: Access-style split pane layout
- Upper pane: Field Name | Data Type | Description grid with clickable row selection
- Lower pane: Property sheet showing column properties (when a field is selected) or table properties (when none selected)
- Column properties include: Field Size, Caption (pg_description), Default Value, Validation Rule (check constraints), Required (inverted nullable), Indexed (from pg_indexes)
- Access-familiar N/A properties shown grayed out: New Values, Format, Input Mask, Validation Text, Allow Zero Length, Unicode Compression, IME Mode, Text Align
- Selection state stored in
:table-viewer :selected-field
- Metadata API (
/api/tables) returns extended column info: maxLength, precision, scale, description, indexed, checkConstraint; plus table-level description - Right-click context menu: New Record, Delete Record, Cut, Copy, Paste
- Tab/Shift+Tab navigation between cells
- State:
state_table.cljs—select-table-field!,set-table-view-mode!,load-table-for-viewing!, cell editing, clipboard ops
- Results View: Runs SQL and displays data in read-only grid
- SQL View: Editable SQL with Run button
- Only SELECT queries allowed for safety
- Split view: VBA source (left) + JS Handlers panel (right)
- Extract Intents button — LLM extracts structured JSON intents from VBA (POST /api/chat/extract-intents)
- Intent summary panel: collapsible, shows procedures with color-coded stats (green=mechanical, yellow=LLM-assisted, red=gap)
- JS Handlers panel: read-only display of generated JavaScript handlers (from
vba-to-js.js) - Info panel: Name, version, imported date, status dropdown
- Modules stored in
shared.objects(type='module'). Definition JSONB containsvba_source,js_handlers,cljs_source,review_notes. Intents stored inshared.intents(FK to objects, intent_type='gesture') - Server libs:
vba-intent-mapper.js(30 intent types, deterministic mapping),vba-intent-extractor.js(LLM extraction),vba-to-js.js(VBA→JS parser) - Transforms:
set-module-intents,set-extracting-intents; Flows:extract-intents-flow
VBA event handlers execute as JavaScript at runtime, NOT via intent tree walking. See skills/event-runtime.md for full details.
- VBA-to-JS parser (
server/lib/vba-to-js.js): Deterministic parser converts VBA procedures into JS strings callingwindow.AC.*methods. Runs at module save time; results stored inshared.objectsdefinition JSONB (definition->'js_handlers'). - Runtime API (
ui/src/app/runtime.cljs):window.ACobject with methods:openForm,openReport,closeForm,gotoRecord,saveRecord,requery,setVisible,setEnabled,setValue,setSubformSource,runSQL. Installed at app init. - Execution: All event paths (button clicks, form events, report events, focus events, after-update) use
(js/Function. js-code)to eval the stored JS. No intent interpreter in the execution path. - No fallbacks: If a handler has no
:jscode, a warning is logged. No silent degradation to caption guessing or intent execution. - Intents are for LLM reasoning only: Intent extraction and the intent map remain for LLM context during translation and the App Viewer's gap decisions pipeline. They do not participate in runtime event dispatch.
- Report events:
state_report.cljsloads handlers fromGET /api/modules/Report_{name}/handlers. Fires:on-openwhen entering preview with data,:on-no-datawhen preview query returns 0 rows,:on-closewhen leaving preview or closing the report tab. - Focus events:
form_view.cljsattaches:on-focus/:on-blurto the.view-controlwrapper div whenfield-triggershas:has-enter-event,:has-exit-event,:has-gotfocus-event, or:has-lostfocus-event. Access semantics: Enter before GotFocus, Exit before LostFocus. - Expression evaluator (
expressions.cljs): Recursive descent parser with tokenizer. Used by computed fields and conditional formatting (NOT by event handlers). Supports field refs[Name], math, string concat&, comparisons,And/Or/Not, built-in functions (IIf, Nz, IsNull, Format, Left/Right/Mid, Len, Trim, etc.), aggregates.truthy?is public.
- Single panel: Raw macro definition (SaveAsText format, read-only)
- Info panel: Name, version, imported date, status dropdown
- Auto-analyze fires on first open, LLM describes structure/purpose
- Macros stored in
shared.objects(type='macro') with append-only versioning. Definition JSONB containsmacro_xml,cljs_source,review_notes
- Whole-application dashboard with tabbed panes: Overview, Gap Decisions, Dependencies, API Surface
- Gap Decisions pane: 2-step pipeline UI:
- Extract All Intents — batch extract from all modules (
batch-extract-intents-flow) - Resolve Gaps — auto-resolved gaps pre-selected where graph context satisfies the reference; remaining gaps presented to user with radio buttons + "Submit All Decisions"
- Extract All Intents — batch extract from all modules (
- Server-side helpers in
context.js:checkIntentDependencies(mappedIntents, graphContext)validates intent references exist;autoResolveGaps(mappedIntents, graphContext)auto-resolves gaps when objects exist - Post-pipeline: users can refine individual modules in Module Viewer
AutoExec macros are now handled automatically by the import pipeline:
disable_autoexec.ps1usesDAO.DBEngine.120(engine-level, no UI trigger) to renameAutoExec→xAutoExecinMSysObjects. The-Restoreswitch reverses it.GET /api/database-import/databasecalls disable before listing, restore after — no manual renaming needed.convert_mdb.ps1also handles AutoExec internally during .mdb → .accdb conversion.
.mdb files (Access 97-2003 format) are automatically converted to .accdb when selected in the import UI:
convert_mdb.ps1disables AutoExec via DAO, converts viaAccess.Application.SaveAsNewDatabase(format 12), restores AutoExec in the original .mdb.- Wired into
GET /api/database-import/database— if the path ends in.mdb, conversion runs first. The response includesconvertedFromso the frontend knows. - The rest of the import pipeline runs unchanged on the resulting
.accdb.
export_table.ps1uses a customConvertTo-SafeJsonserializer instead ofConvertTo-Json— PowerShell's built-in cmdlet has known bugs with embedded double quotes in large strings (e.g. HTML in memo fields). The custom serializer handles escaping of",\,\r,\n,\tcorrectly.list_modules.ps1,export_module.ps1,export_modules_batch.ps1handle Form_/Report_ class modules (VBE type 100) with a design-view fallback: ifCodeModule.CountOfLinesis inaccessible (common withAutomationSecurity=3), the script opens the form/report in design view viaDoCmd.OpenForm/DoCmd.OpenReportto force the code module to load, then closes it after reading. The listing script only skips type 100 modules with confirmed zero lines (not inaccessible ones).
State is split across three modules that share a single Reagent atom (app-state):
Core state (ui/src/app/state.cljs):
- Shared helpers, loading/error, database selection, tabs, UI persistence, chat, config
load-databases!/switch-database!reload all 7 object types: tables, queries, functions, forms, reports, modules, macros- Records use keyword keys internally, converted to strings for API
Form state (ui/src/app/state_form.cljs):
set-view-mode!- Switches between :design and :view modes, loads datasave-current-record!- Handles both INSERT (new) and UPDATE (existing)navigate-to-record!- Auto-saves before navigation- Row-source cache, subform cache, clipboard, form normalization
create-new-form!,load-form-for-editing!,save-form!,select-control!,update-control!,delete-control!
Report state (ui/src/app/state_report.cljs):
set-report-definition!,load-report-for-editing!,save-report!,set-report-view-mode!,select-report-control!,update-report-control!,delete-report-control!
All errors and significant actions are logged to the shared.events table with propagation signatures.
Server-side (server/lib/events.js):
logError(pool, source, message, err, { databaseId, objectType, objectName })— logs with stack trace, event_type='error'. Returns inserted event id.logEvent(pool, eventType, source, message, { databaseId, details, parentEventId, objectType, objectName, propagation })— general events. Returns inserted event id.- Ledger columns:
parent_event_id(causal chain),object_type/object_name(primary object),propagation(JSONB — graph nodes/edges touched, intents affected, evaluations generated, secondary objects) - Source naming convention:
"METHOD /api/path"e.g."GET /api/tables","POST /api/data/:table" - Use
logErrorfor actual errors; uselogEvent(pool, 'warning', ...)for:- Graceful degradation (sessions GET returning empty
{}) - Non-fatal side effects (graph population after form/report save)
- Chat tool errors (with tool name in details)
- Graceful degradation (sessions GET returning empty
Frontend (state.cljs):
log-error!(message, source, details) — callsset-error!(UI banner) +log-event!(POST to server)log-event!(event-type, message, source, details) — POST to/api/events, no UI banner- Use
log-error!for user-facing errors (form load, save, delete failures) - Use
log-event!for background errors where a banner would be disruptive (subform loading, query execution when error state is already shown in UI)
/api/data/:table- CRUD operations for table records/api/databases- Multi-database management/api/forms/*- Form CRUD with append-only versioning (shared.objects WHERE type='form')/api/reports/*- Report CRUD with append-only versioning (shared.objects WHERE type='report')/api/graph/*- Dependency/intent graph queries/api/session/ui-state- Save/load UI state (open tabs, active database)/api/queries/run- Execute SQL queries (SELECT only)/api/queries/execute- Execute INSERT/UPDATE/DELETE SQL (no SELECT/DDL, single-statement)- Schema routing via X-Database-ID header
Three endpoints for validating form and report definitions:
POST /api/lint/form— Structural validation (required fields, control types, dimensions) + cross-object validation (record-source exists, field bindings match real columns, combo-box SQL is valid via EXPLAIN)POST /api/lint/report— Same pattern for reports: structural validation of banded sections + cross-object field binding checksPOST /api/lint/validate— Database-wide: loads all forms/reports from shared.objects (type IN ('form','report')), validates each, returns aggregated results with summary
Save flow in both editors: lint first → if valid, save; if invalid, show errors in .lint-errors-panel; if lint endpoint fails, save anyway (graceful degradation). Cross-object checks use getSchemaInfo() which queries information_schema for all tables/views and their columns.
Deterministic evaluation of form and report definitions, recorded in shared.evaluations.
- Automatic on save: Both
PUT /api/forms/:nameandPUT /api/reports/:namerunrunAndRecordEvaluationas a post-commit side effect (wrapped in try/catch — never blocks save). Response includesevaluationfield. - On-demand endpoints:
POST /api/evaluations/:type/:name/run(single object),POST /api/evaluations/run-all(all forms+reports in database). Both usetrigger: 'manual'. - History endpoints:
GET /api/evaluations/:type/:name(paginated),GET /api/evaluations/:type/:name/latest. - Form checks (4): record_source_exists, structural_lint, control_bindings_match, combo_sql_valid
- Report checks (3): record_source_exists, structural_lint, control_bindings_match (no combo-box)
- Failure classification:
classifyFailure()maps check failures →missing_dependency | translation_ambiguity | regression | structural_error | semantic_mismatch | unsupported_pattern - No FK to shared.objects — objects use append-only versioning where rows get
is_current = false.object_idstored for correlation only. runFormDeterministicChecksandrunReportDeterministicChecksboth accept string (object name) or task object (pipeline path) as thetaskparameter — backward-compatible.
A structural graph in shared._nodes and shared._edges:
- Structural nodes: tables, columns, forms, controls (all with database_id, scope='local')
- Edges: contains, references, bound_to
Populated once on first startup from schemas. Forms/reports auto-update when saved.
After schema changes (new tables/columns), call POST /api/graph/populate to sync.
LLM tools in chat: query_dependencies
The chat system prompt includes context based on the active tab:
- Forms:
form_contextwithrecord_source+ full definition →summarizeDefinition()renders compact text (sections, controls with type/field/position) - Reports:
report_contextwithreport_name,record_source+ full definition → samesummarizeDefinition()helper - Modules:
module_contextwith VBA source, app object inventory. Also:POST /api/chat/extract-intentsfor structured intent extraction - Graph tools: Always available for dependency/intent queries
- Three Horse: When
database_id === 'threehorse', the entire system prompt is replaced withskills/three-horse-chat.md(loaded at startup). Page-specific context appended based on which form is open. Usesclaude-haiku-4-5(cheap model) with no tools (pure conversation).
Auto-analyze: When a report or form is opened with no existing chat transcript, maybe-auto-analyze! in state.cljs automatically sends a prompt asking the LLM to describe the object's structure/purpose and flag potential issues. Uses a pending-flag pattern to handle the race between transcript loading and definition loading — whichever async operation completes second triggers the analysis. The generated analysis is saved as the transcript so it won't re-fire on subsequent opens.
Converts Access SQL to PostgreSQL views/functions. Two-stage pipeline:
- Regex converter (fast, deterministic, free):
index.jsorchestrates;syntax.jshandles brackets/operators/schema-prefixing;functions.jsmaps Access→PG functions;ddl.jsgenerates CREATE VIEW/FUNCTION DDL;form-state.jsresolves form/TempVar references via cross-joins againstshared.session_state. - LLM fallback (
llm-fallback.js): When regex output fails PG execution, sends original Access SQL + error + full schema context (tables, views, columns, available functions) + control mapping to Claude Sonnet. LLM-assisted conversions flagged inshared.import_issueswith categoryllm-assisted. Dependency errors (42P01/42883) skip the LLM fallback — the frontend retry loop handles these across passes. Error responses includecategory: 'missing-dependency' | 'conversion-error'.
VBA stub functions (server/lib/vba-stub-generator.js) are created before query execution so views referencing user-defined functions don't fail.
Re-import behavior: Queries use CREATE OR REPLACE VIEW/FUNCTION, so re-importing replaces existing objects in-place without affecting dependent views. If a view's column list changed, executeStatements catches the error, does a targeted DROP CASCADE + CREATE for just that view.
95 tests in server/__tests__/query-converter.test.js. Run after any converter changes.
See skills/form-state-sync.md for full architecture. Key points:
shared.session_state: a view onform_control_statepre-filtered bycurrent_setting('app.session_id', true)— used as cross-joins in converted queriesshared.control_column_map: maps(database_id, form_name, control_name)→(table_name, column_name), populated at form/report saveshared.form_control_state: runtime state(session_id, table_name, column_name, value), populated when users navigate records in forms with tagged controls- Query converter resolves
[Forms]![frmX]![ctrl]and[TempVars]![var]to cross-join aliases (ss1.value,ss2.value) againstshared.session_state, with filtering in WHERE - Import order matters: tables → forms/reports → queries (forms must exist before queries to populate the mapping)
Both import-selected! and import-all! use a multi-pass retry loop for queries to handle dependency ordering (query A depends on query B which depends on query C). Each pass imports what it can; failed queries are retried in the next pass. The loop continues while progress is made (at least one query succeeded), up to 20 passes max. Dependency errors (PG 42P01/42883) are identified server-side and returned with category: 'missing-dependency'.
See MANIFEST.md for a complete index of all .md files with summaries and startup-priority flags.
On session start, read these files in full:
CLAUDE.md(this file)HANDOFF.md— current state, recent changes, known landminesintents.json— project orientations and directions (why decisions were made, what's in tension)tasks.md— pending/completed tasks
All other files: consult the manifest summary, read in full only when working on that topic.
When recording significant changes in HANDOFF.md, follow skills/trace-convention.md — double-entry format with expression (what changed) and corona (what it is of, by reference to theoretical documents).
Always run after making changes: npm test (from project root — runs server + electron tests)
| What you changed | What to run | Test file |
|---|---|---|
Query converter (server/lib/query-converter/) |
npm test |
server/__tests__/query-converter.test.js (95 tests) |
Lint / validation (server/routes/lint/) |
npm test |
server/__tests__/lint.test.js |
VBA stubs (server/lib/vba-stub-generator.js) |
npm test |
server/__tests__/vba-stub-generator.test.js |
VBA intent mapper (server/lib/vba-intent-mapper.js) |
npm test |
server/__tests__/vba-intent-mapper.test.js (~24 tests) |
VBA intent extractor (server/lib/vba-intent-extractor.js) |
npm test |
server/__tests__/vba-intent-extractor.test.js (~12 tests) |
| Schema routing / multi-DB middleware | npm run test:db |
server/__tests__/db.schema-routing.test.js (needs PostgreSQL) |
Electron utilities (electron/lib/) |
npm test |
electron/__tests__/*.test.js |
| Pattern verification (intents.json patterns) | npm test or npx jest --testPathPattern=patterns/ |
server/__tests__/patterns/*.test.js (21 tests) |
DB tests require a running PostgreSQL instance and are gated behind ACCESSCLONE_DB_TESTS=1. They do NOT run with npm test — use npm run test:db explicitly.
Pattern tests verify that selected patterns in intents.json have their infrastructure, generated output, and pipeline integration intact. Each pattern with a tests field points to an executable Jest file. Run npx jest --testPathPattern=patterns/ from server/ for just pattern tests.
See skills/testing.md for the full guide including how to write new tests.
If a form's View mode shows no records but the table has data:
- Check record-source in Design view Property Sheet matches table name exactly
- Open the table directly from sidebar to verify data exists
- Check Network tab - look at
/api/data/{table}response - Check X-Database-ID header is correct
- Try deleting and re-importing the form from Access
Resolved (2026-02-04): List_of_Carriers form was not displaying data due to two issues:
- The
:fieldproperty had "Carrier" (capital C) but the database column was "carrier" (lowercase). Fix: field lookup is now case-insensitive (normalized to lowercase). - The
:typeproperty was a string"text-box"after save+reload (JSON round-trip converts keywords to strings), but thecasestatement matched only keywords:text-box. Fix:normalize-form-definitionin state_form.cljs now coerces all control:typevalues to keywords, plus yes/no and number properties, on load. Code can safely match keywords directly.