What is FoxPro Ninja?
FoxPro Ninja (powered by Ninja) is a self-hosted server that exposes your existing Visual FoxPro and DBF data over a standard REST HTTP API. It sits alongside your running FoxPro installation — reading and writing the same files — without requiring any schema migration, database replacement, or downtime.
Once running, any web application, reporting tool, mobile app, or integration script can query and update your FoxPro data using ordinary HTTP requests and JSON. No FoxPro SDK, no ODBC driver, no special client library required.
FoxPro Ninja is built around three layers:
- Layer 1 — Table API. Every DBF table in your data directory is immediately available as a REST endpoint. No configuration needed beyond pointing the server at your data folder.
- Layer 2 — Aggregate Engine. Named, YAML-defined operations that combine multiple tables into a single transactional unit, enforcing the same business rules your FoxPro screens relied on.
- Layer 3 — Admin UI. A browser-based interface for exploring tables, browsing data, and managing aggregate definitions.
Architecture
FoxPro Ninja is a single binary with no external runtime dependencies. It runs on the same Windows server as your FoxPro application, directly alongside it.
Incoming HTTP requests are authenticated, routed to the appropriate handler (table CRUD or aggregate engine), and executed against your DBF files by the NinjaCore. Every write is journaled to an append-only audit log before the response is returned to the caller.
Browser / Reporting Tool / Integration
│
│ HTTPS (Bearer token)
▼
┌─────────────────────────────────┐
│ Ninja server │
│ │
│ Auth → Layer 1 (Table CRUD) │
│ → Layer 2 (Aggregates) │
│ → Layer 3 (Admin UI) │
│ │
│ ┌──────────────────────────┐ │
│ │ NinjaCore │ │
│ │ (read / write / index) │ │
│ └──────────┬───────────────┘ │
└─────────────┼───────────────────┘
│
┌─────────────▼───────────────────┐
│ Your data directory │
│ *.DBF *.FPT *.CDX *.DBC │
└─────────────────────────────────┘
▲
│ (FoxPro still runs here too)
Your FoxPro Application
FoxPro and Ninja coordinate through the same file-level locking mechanisms FoxPro has always used — so both can be active simultaneously without conflict.
NinjaCore
At the core of FoxPro Ninja is NinjaCore — a proprietary, high-performance engine developed by F8 Labs for reading and writing DBF, FPT (memo), and CDX (compound index) files with full Visual FoxPro compatibility.
The engine provides:
- Full VFP field type support — Character, Numeric, Float, Integer (including autoincrement), Date, DateTime, Logical, Memo (text FPT), Currency, Double, Blob, and variable-length Varchar/Varbinary. Every surfaced type round-trips correctly through the JSON API. General/OLE (
G) and Picture (P) columns carry raw binary blobs that cannot be expressed as JSON; they are intentionally omitted from schema and row responses. - Nullable column support — columns marked as nullable in the VFP Database Designer return JSON
nullwhen the VFP null flag is set, rather than an empty string, zero, or sentinel value. TheGET /tables/{name}schema endpoint reports which columns are nullable. - CDX index maintenance on every write — structural, compound, and tag-filtered indexes are kept current so FoxPro SEEK operations continue to work correctly after API writes.
- FPT file support — both text memo fields and binary General/OLE fields read from and write to the companion
.fptfile. - Live header refresh on every read — rows appended by FoxPro between API requests are visible immediately, without a restart or file close/reopen.
- Row-level locking using VFP's standard byte-range locking protocol, enabling safe concurrent access alongside a live FoxPro session.
- DBC metadata parsing — reads the Database Container to discover long table names, field defaults, validation rules, and referential integrity relationships defined in the VFP database designer.
NinjaCore is proprietary to F8 Labs and ships as part of the Ninja binary. It is not separately available.
FoxPro Coexistence
FoxPro Ninja is designed from the ground up to run alongside a live FoxPro application on the same data files. You do not need to stop FoxPro, take a backup, or schedule a maintenance window to run the API.
How locking works
When Ninja writes a row, it acquires the same byte-range record lock that
FoxPro's RLOCK() uses. If FoxPro already holds the lock, the API
request waits (up to the configured timeout) and retries. FoxPro behaves
identically when the API holds a lock — neither side blocks the other's reads,
only concurrent writes to the same row.
Read visibility
NinjaCore re-reads the DBF header on every scan and get operation. Rows that FoxPro appends between API requests are visible immediately — no restart or cache invalidation required.
Starting in read-only mode
We recommend starting with read_only: true in your config while
you validate that the API is returning correct data. In read-only mode all
POST, PATCH, and DELETE requests are rejected at the HTTP layer — FoxPro
remains the only writer. Flip the flag when you're confident.
Do not run FoxPro's PACK command while Ninja is active. PACK physically removes deleted rows and renumbers every record. Any pagination cursor issued before the PACK will reference a recno that no longer points to the same row. Schedule PACK during a maintenance window when Ninja is stopped. FoxPro itself enforces this — PACK requires an exclusive table lock, which fails while Ninja holds the file open.
Ninja detects PACK automatically. If a PACK occurs between a Ninja stop and restart, the engine notices the row-count change on the first read and logs an error. Check GET /health/details — the pack_events field lists any detected occurrences with the table name, timestamp, and before/after record counts.
DBC Support
If your FoxPro application uses a Database Container (.dbc) —
which Visual FoxPro shops typically do — Ninja reads it at startup and uses
its metadata throughout the engine's lifetime.
From the DBC, Ninja extracts and enforces:
- Long table names — the DBC display name is used in API responses and accepted in URL paths alongside the 8.3 short name.
- Field defaults —
DefaultValueexpressions are evaluated when a field is omitted from an insert body. - Validation rules —
RuleExpressionconstraints are checked on writes using a bounded expression evaluator. - Referential integrity —
RIINFOcascade relationships (restrict, cascade, set null) are enforced in the engine on deletes and key-updates, including multi-level cascades with cycle detection.
Point data_dir at the folder containing your .dbc file and the engine discovers everything automatically. No extra configuration is needed to enable DBC support.
Validating Your Data
Before going live, confirm that Ninja can open every table in your data directory and read every field type without error. This is especially important if your schema uses less-common VFP types — nullable columns, autoincrement integers, Currency, or General/OLE binary fields.
Pre-flight: start Ninja and check /health/details
Start Ninja pointed at your data directory (in read-only mode if preferred) and call the health endpoint with an admin token:
GET /health/details Authorization: Bearer <admin-token>
The response tells you everything Ninja found at startup:
tables— list of every DBF file that was opened, with its row count and column count. A table missing here means Ninja could not open it (bad path, corrupt header, or unsupported version).stale_on_write_tags— CDX index tags that Ninja cannot maintain on writes because their key expressions use FoxPro functions that require a live VFP evaluator. Ninja logs these at startup so you know to let FoxPro rebuild them after a write session. A clean installation shows an empty array.pack_events— any PACK or ZAP operations detected since the last start (table name, timestamp, before/after row count). Should be empty; a non-empty list means a PACK ran while Ninja was stopped and pagination cursors issued before that PACK are now stale.
A clean pre-flight shows every expected table in tables, empty stale_on_write_tags and pack_events, and the status ok at the top level. If a table is missing, check the Ninja log for the open error — it names the exact file.
Spot-checking field types
Once the server is running, call GET /tables/{name} on any
table to see its schema, including which columns are nullable and what VFP
type each column uses. Then call
GET /tables/{name}/rows and compare a few rows
against what FoxPro shows for the same records:
- Nullable columns — where VFP shows a blank or empty value on a nullable column, the API returns
null. Where VFP shows a real value, the API returns the typed value. Both cases should match. - Currency fields — appear as a JSON string with exactly four decimal places (e.g.
"18.0000"), not a bare number. - General/OLE fields — do not appear at all. General/OLE (
G) and Picture (P) columns are omitted from both the schema endpoint and row responses; you will not see them in the output. Read or write OLE/Picture data directly through FoxPro. - Autoincrement integers — read back as a plain JSON integer. Ninja assigns the next autoincrement value automatically on insert; do not include the column in your POST body. The schema endpoint marks these columns with
"autoincrement": true.
System Requirements
| Component | Requirement |
|---|---|
| Operating System | Windows Server 2012 R2 or later (primary). Linux and macOS supported for development and cloud-edge use. |
| Architecture | x64 (AMD64) |
| Disk space | 50 MB for the binary + audit log growth (typically 1–10 MB/day depending on write volume) |
| Memory | 64 MB minimum; 256 MB recommended for production with large table scans |
| Network | TCP port of your choice (default: 8080). HTTPS termination via a reverse proxy or Cloudflare Tunnel is recommended for non-loopback access. |
| FoxPro | FoxPro 2.x or Visual FoxPro 6–9. The data directory must be accessible from the machine running Ninja (local drive or UNC path). |
Ninja is a single self-contained executable. There is no installer, no runtime to install, and no dependencies beyond the binary itself.
Download
Contact sales@foxproninja.com to receive a licensed binary for your platform. Each release includes:
ninja.exe— the server binary (Windows x64)ninja-linux— Linux x64 build (for cloud-edge or development)config.example.yaml— annotated configuration templateTHIRD_PARTY_LICENSES.txt— open-source attribution notices
Place the binary in a folder of your choice — alongside your FoxPro application
or in a dedicated directory such as C:\F8Labs\Ninja\.
Configuration File
Ninja is configured by a YAML file. By default it looks for
config.yaml in the current directory. Pass a different path with
the -c flag:
ninja.exe -c C:\F8Labs\Ninja\config.yaml
A minimal configuration for read-only evaluation alongside live FoxPro:
# Path to the folder containing your DBF / DBC files data_dir: C:\AccountingData\appdata # Bind to loopback only while evaluating bind_addr: 127.0.0.1:8080 # Read-only mode: GET requests only, no writes read_only: true # Require a Bearer token even in read-only mode auth: static_token: change-me-to-a-long-random-string
A full production configuration with per-scope tokens:
data_dir: C:\AccountingData\appdata bind_addr: 0.0.0.0:8080 # Remove read_only (or set to false) to enable writes read_only: false # Expose only the tables you want visible over the API expose_tables: - apvend - apbatch - glacct # Per-scope tokens: read, write, admin auth: static_tokens: read: token-for-reporting-ui write: token-for-write-access admin: token-for-admin-ui # CORS — required if a browser UI calls the API directly cors: allow_origins: - https://yourapp.example.com # Aggregate YAML definitions directory aggregates_dir: C:\F8Labs\Ninja\aggregates # Branding (shown in the admin UI header) branding: service_label: Acme Corp # Counter backend (use registry when running alongside FoxPro/AM) counters: backend: registry
Configuration Reference
| Key | Type | Default | Description |
|---|---|---|---|
data_dir | string | required | Path to the directory containing your DBF files (and optionally a .dbc file). |
bind_addr | string | 127.0.0.1:8080 | TCP address to listen on. Use 0.0.0.0:8080 to accept connections from other machines. Auth is required for non-loopback addresses. |
read_only | bool | false | When true, all POST/PATCH/PUT/DELETE requests are rejected. Reads continue normally. |
expose_tables | []string | all tables | Whitelist of table names to expose. Omit to expose all discovered tables. |
aggregates_dir | string | — | Directory of YAML aggregate definitions. If omitted, /aggregates routes are not mounted. |
auth.static_token | string | — | Single bearer token granting all scopes. Use this for simple single-client setups. |
auth.static_tokens | map | — | Map of scope → token. Valid scopes: read, write, admin. Takes precedence over static_token. |
auth.allow_public_access | bool | false | Skip auth enforcement (for use behind Cloudflare Access or similar external auth). Disables the bind-address + CORS safety checks. |
cors.allow_origins | []string | [] | List of allowed CORS origins for browser requests. Wildcard (*) is rejected unless allow_public_access is true. |
counters.backend | string | file | file (standalone JSON store) or registry (reads/writes appreg01.dbf — required when running alongside a FoxPro application that uses a DBF counter registry). |
counters.registry_file | string | appreg01.dbf | Filename of the counter registry DBF, relative to data_dir. Used only when backend: registry. |
branding.service_label | string | Ninja | Label shown in the admin UI header and server logs. |
log_level | string | info | Structured log verbosity: debug, info, warn, error. |
Starting the Server
# Start with default config.yaml in current directory ninja.exe # Start with an explicit config path ninja.exe -c C:\F8Labs\Ninja\config.yaml # Print version ninja.exe -version # Print third-party license notices ninja.exe -credits
On startup, the server logs a summary of what it found — table count, DBC status, loaded aggregates, and any CDX tags it cannot maintain. Review this output before going live.
The server accepts SIGINT (Ctrl+C) and SIGTERM for graceful shutdown. In-flight requests are given up to 65 seconds to complete before the process exits.
Verifying It Works
Hit the liveness endpoint — no token required:
curl http://127.0.0.1:8080/health
{ "status": "ok" }
Then fetch the full diagnostic with your admin token:
curl -H "Authorization: Bearer <admin-token>" \
http://127.0.0.1:8080/health/details
The response shows the table count, DBC status, loaded aggregates, and any CDX tags the engine cannot maintain. A healthy installation looks like:
{ "status": "ok", "tables": 185, "has_dbc": true, "unsupported_defaults": [], "unsupported_rules": [], "stale_on_write_tags": [] }
Non-empty stale_on_write_tags is not an error — it means a small
number of CDX tags use expressions the engine can't maintain (typically
STR()-based date arithmetic keys). FoxPro will rebuild them
correctly; the API simply cannot keep them current during its own writes.
See Health & Diagnostics for a full explanation of both fields.
Running as a Windows Service
For production deployments on Windows, run Ninja as a service so it starts automatically and restarts on failure. We recommend NSSM (the Non-Sucking Service Manager), a free utility that wraps any executable as a Windows service.
# Install NSSM from nssm.cc, then:
nssm install ninja "C:\F8Labs\Ninja\ninja.exe"
nssm set ninja AppParameters "-c C:\F8Labs\Ninja\config.yaml"
nssm set ninja AppDirectory "C:\F8Labs\Ninja"
nssm set ninja Start SERVICE_AUTO_START
nssm start ninja
A graceful SIGTERM allows in-flight requests to finish (up to 65 seconds). If the service manager sends a hard kill (e.g. Windows Update restart), any aggregate operation in progress will be incomplete. See the Audit Log section for how to detect and recover from a torn write.
Authentication
All requests to a secured server must include a Bearer token in the
Authorization header:
Authorization: Bearer your-token-here
Three permission levels (scopes) exist:
| Scope | Grants access to |
|---|---|
read | GET on all table and aggregate endpoints |
write | Everything in read, plus POST / PATCH / PUT / DELETE on tables and aggregates |
admin | Everything in write, plus the Admin UI and /health/details |
Scopes are cumulative — an admin token also has read and write access.
Use separate tokens for each scope to limit what a compromised client can do:
give a reporting dashboard only the read token.
Error responses
| HTTP | Code | Meaning |
|---|---|---|
| 401 | invalid_token | Token missing or not recognised |
| 403 | scope_required | Token valid but insufficient scope for this endpoint |
Tables — Layer 1
Every DBF table in your data directory (subject to expose_tables
filtering) is available under /tables. All responses use
lowercase snake_case JSON keys; column names in
?where= filters must be uppercase to match the DBF schema.
List tables
{ "items": [ { "name": "apvend", "long_name": "AP Vendor Master", "row_count": 412 } ] }
Describe a table
Returns column names, types, lengths, and nullability. Use this to discover what fields are available before querying.
Insert a row
{ "CVENDNO": "000999", "LNAME": "New Vendor", "LACTIVE": true }
{ "key": "AAAAAAAB" }
The returned key is an opaque row handle. Use it for get/update/delete. Do not use it as a business key — use the table's own identifier column (e.g. CVENDNO) for that.
Get a row
Update a row
Partial update — only the fields you include are changed. PUT is accepted as an alias with identical semantics.
Delete a row
Soft-deletes the row (sets the DBF deletion flag). The row is hidden from all subsequent API responses. FoxPro's PACK command removes soft-deleted rows permanently.
Field types in API responses
All fields are returned under their DBF column name, lowercased and converted to snake_case. The JSON type depends on the VFP field type:
| VFP type | JSON | Notes |
|---|---|---|
Character (C), Varchar (V) | string | Trailing spaces trimmed. Nullable variant returns null when the VFP null flag is set. |
Memo (M) | string | Full text content from the .fpt file. Can be arbitrarily long — do not assume it fits in a header or a log line. Returns "" if the FPT file is absent. |
General / OLE (G), Picture (P) | omitted | These columns carry raw binary blobs that have no JSON representation. They do not appear in schema responses or row responses — the column simply isn't surfaced by the API. Use FoxPro directly to read or write OLE / Picture data. |
Numeric (N), Float (F), Double (B) | number | Nullable variant returns null. |
Integer (I) | number (integer) | Autoincrement columns are assigned automatically by Ninja on insert — do not include them in the POST body. The schema endpoint reports "autoincrement": true for these columns. Nullable variant returns null. |
Currency (Y) | string | Exactly four decimal places in canonical form (e.g. "18.0000"). Stored in VFP as a fixed-point int64 × 10⁻⁴; the string form preserves precision through JSON encode/decode. |
Date (D) | string (YYYY-MM-DD) | Nullable variant returns null. |
DateTime (T) | string (ISO 8601) | |
Logical (L) | boolean |
The FPT file must travel with the DBF. Memo fields store their data in a companion .fpt file with the same base name as the DBF. If the FPT is missing or inaccessible, memo fields return empty strings. Ninja logs a warning at startup for any DBF that has memo columns but no FPT file. General/OLE (G) and Picture (P) columns are omitted from the API entirely regardless of FPT availability.
Querying & Filtering
The ?where= parameter accepts one or more predicates
comma-separated. All predicates are AND-ed. Column names must be
uppercase.
| Operator | Example | Meaning |
|---|---|---|
eq | CVENDNO:eq:000123 | Equal |
ne | LACTIVE:ne:T | Not equal |
lt | NAMOUNT:lt:1000 | Less than |
le | DDATE:le:2026-12-31 | Less than or equal |
gt | NAMOUNT:gt:0 | Greater than |
ge | DDATE:ge:2026-01-01 | Greater than or equal |
in | CSTATE:in:TX|CA|NY | Value in pipe-separated set |
is_null | DMEMO:is_null | Field is null / empty |
eq:null | NQTY:eq:null | Field is null — equivalent to is_null, works for any column type |
ne:null | NQTY:ne:null | Field is not null |
like | LNAME:like:Acme% | Pattern match (% = any, _ = one char) |
Combining predicates (AND):
GET /tables/apbatch/rows?where=CVENDNO:eq:000123,LACTIVE:eq:T
Sort results with ?order_by=COLNAME (repeatable for multi-column sort):
GET /tables/apvend/rows?order_by=LNAME&order_by=CVENDNO
Pagination
All row scan responses include a next_cursor field when more rows
follow the current page. Pass it as ?cursor= to retrieve the next page.
Omit ?cursor= to start from the beginning.
| Parameter | Default | Max | Description |
|---|---|---|---|
limit | 100 | 1000 | Rows per page. Values above 1000 are silently capped. |
cursor | — | — | Continuation token from the previous response's next_cursor. |
cursor = "" loop: response = GET /tables/apvend/rows?limit=500&cursor={cursor} process(response.items) if response.next_cursor: cursor = response.next_cursor else: break
Cursor tokens are opaque — do not construct or parse them. A cursor from a sorted scan (?order_by=) cannot be mixed with an unsorted scan; the server returns 400 invalid_argument if you try.
Aggregates — Layer 2
Aggregates are named operations that combine multiple tables, enforce business rules, allocate sequential numbers, and journal their changes atomically. Use aggregates — not raw table inserts — for any record that participates in a counter sequence (bills, invoices, payments, GL entries).
List aggregates
Returns all loaded aggregates and a list of any that failed validation at startup.
Create a resource
POST /aggregates/bill { "vendor_no": "000123", "amount": 1250.00, "due_date": "2026-07-15", "description": "Office supplies" } HTTP 201 Created → batch number allocated from appreg01.dbf vendor FK verified header + detail rows written in one journal CDX indexes updated
Dry-run before committing
Same request body as Create. Returns the planned writes and counter preview without committing anything. Use this to validate input before going live.
Read an assembled resource
Returns the full assembled resource — header joined with all detail lines — for a given business key (e.g. batch number, invoice number).
Update a resource
Partial update. Only the fields you supply are changed. PUT accepted as an alias.
Delete a resource
Soft-deletes all rows associated with the resource per the aggregate's delete plan (cascade).
Admin UI
When the admin option is enabled in the server, a browser-based interface is
available at /admin. It requires an admin-scoped token.
The admin UI provides:
- A searchable list of all exposed tables with row counts
- Per-table data browsing with sorting and filtering
- Aggregate definition viewer
- Recent audit log entries
The admin UI is served from the binary itself — there are no external files to deploy.
Health & Diagnostics
Two health endpoints are available:
| Endpoint | Auth | Use |
|---|---|---|
GET /health | None | Liveness probe. Returns {"status":"ok"}. Safe for load balancers and uptime monitors. |
GET /health/details | admin | Full diagnostic snapshot. See fields below. |
Understanding /health/details
A healthy installation returns a response like this:
{ "status": "ok", "tables": 185, "has_dbc": true, "stale_on_write_tags": [], "pack_events": [] }
stale_on_write_tags
Ninja maintains every CDX index tag on every write — but only when it can
evaluate the tag's key expression in Go. A small number of tags use FoxPro
functions that require a live VFP evaluator (typically STR()-based
date arithmetic like STR(YEAR(date),4)+STR(MONTH(date),2,0)).
These tags are detected at startup and listed in stale_on_write_tags.
What it means: After Ninja writes a row on a table that has stale tags, those specific index entries will be out of date until FoxPro next opens the table. FoxPro's own writes and REINDEXes correct them automatically. Every other index tag on the same table is maintained correctly.
What to do: Nothing urgent. Check the list at startup. If a stale
tag is on a CDX that your FoxPro code SEEKs heavily, coordinate with the FoxPro
side to run REINDEX after a write session. If Ninja is running
read-only, stale tags are irrelevant — Ninja never touches the CDX.
Ninja logs every stale tag at startup with the table name and tag name. The log output and /health/details show the same list.
pack_events
FoxPro's PACK and ZAP commands physically remove
soft-deleted rows and renumber every remaining record. Because these require an
exclusive lock, they cannot run while Ninja holds the file open — they must be
done during a maintenance window when Ninja is stopped.
On every restart, Ninja compares each table's current row count to the count
it saw before shutdown. If the count dropped, it records a
pack_events entry with the table name, the timestamp of detection,
and the before/after counts.
What it means: Any cursor or recno reference issued before the PACK now points to a different row. Clients that cached a cursor should discard it and re-paginate from the beginning.
Recommended workflow after a maintenance PACK: restart Ninja →
call /health/details → if pack_events is non-empty,
notify any connected clients to reset their pagination state.
Audit Log
Every committed write — Layer-1 or aggregate — is appended to a daily JSONL
file in a .ninja-audit/ directory inside data_dir.
Each entry records the timestamp, operation type, table(s) affected, row key,
and the requesting user's token scope.
The audit log is append-only and never modified by the server. It is suitable for compliance archiving and for diagnosing what happened after an unexpected result.
The audit log grows indefinitely. Implement an OS-level log rotation policy (e.g. Windows Task Scheduler deleting files older than 90 days) to prevent unbounded disk growth.
FoxPro Legacy Development Best Practices
Running Ninja alongside a live FoxPro or Visual FoxPro application does not require changes to your FoxPro code — the two systems share DBF files at the OS level and coexist today. But certain FoxPro coding patterns, common in single-user or early multi-user applications, become friction points when a second process is reading and writing the same data. The practices below are worth reviewing if you have the ability to touch the FoxPro source code. None are mandatory; each is a trade-off.
These apply equally whether you are modernizing a custom FoxPro application or any other DBF-based system. Prioritize the items that match your most active tables and highest-traffic code paths.
Prefer Record Locks over Table Locks
FoxPro offers two locking scopes: RLOCK() (record-level) and FLOCK()
(file/table-level). A table lock acquired with FLOCK() blocks every other process —
including Ninja — from writing to that table until the lock is released. In a single-user app this
is invisible; with Ninja active, a long-running FLOCK() stacks up Ninja write requests
behind it, and a Ninja write in progress blocks the FoxPro side from acquiring the table lock.
What to do: Audit routines that call FLOCK() and convert them to
RLOCK() on the specific records they need to modify. This is the most impactful single
change for coexistence throughput.
Already using RLOCK()? You're in good shape. Record-level locks
cooperate naturally with Ninja's own per-operation locking — only the specific rows being written
are locked, and only for the duration of the write.
What to watch for: Code that loops over a table and calls FLOCK()
once at the top, then processes every record, then calls UNLOCK(). This pattern holds
a table lock for seconds or minutes during busy processing. Break it into per-record
RLOCK()/UNLOCK() pairs, or batch the records into memory first and
release the lock before processing.
Avoid Exclusive Opens During Normal Operation
USE tablename EXCLUSIVE takes an OS-level exclusive lock on the DBF file. No other
process — including Ninja — can open or read the table until the exclusive session ends. FoxPro
requires exclusive access for structural operations (adding fields, rebuilding indexes, running
PACK), but many older applications use it out of habit for ordinary data entry.
What to do: Identify routines that open tables exclusively and ask whether they actually need it. Most data-entry and reporting code does not. Reserve exclusive opens for structural operations and run those during a maintenance window when Ninja is stopped.
A FoxPro session that holds a table exclusive will cause Ninja to return errors for any request touching that table until the exclusive session closes. If you see intermittent 503 or timeout errors from Ninja, an exclusive open in the FoxPro code is a likely cause.
Common pattern to fix: A print or report routine that opens the table exclusively
"to get a consistent snapshot." Replace it with shared access plus RLOCK() on the
rows being read, or restructure to read the data into an array before printing.
Schedule PACK, ZAP, and REINDEX in Maintenance Windows
These three commands require exclusive table access (FoxPro enforces this — they will not run while any other session has the table open) and they change the physical layout of the DBF file:
- PACK — physically removes soft-deleted rows and renumbers every remaining record. Any API cursor or recno reference from before the PACK now points to a different row.
- ZAP — deletes every row instantly (equivalent to PACK after deleting all records). Faster than DELETE ALL + PACK, but equally disruptive to any live references.
- REINDEX — rebuilds the CDX from scratch. Safe for the data, but invalidates the index temporarily and requires exclusive access.
Because FoxPro cannot acquire the exclusive lock while Ninja holds the file open, these commands will fail during normal API operation — which is protective. The risk is the restart-after-operation window: if you run PACK while Ninja is stopped and then restart Ninja without clearing outstanding client cursors, those cursors are now referencing wrong recnos.
Ninja detects PACK and ZAP automatically. On the first read after restart,
the engine compares the current row count to the count it saw before shutdown. If it dropped,
a pack_events entry appears on GET /health/details with the table,
timestamp, and before/after counts. Check this after every maintenance window that included
PACK or ZAP.
Recommended workflow: Stop Ninja → run PACK/ZAP/REINDEX → restart Ninja →
check /health/details for pack_events → notify any API clients to
discard their cursors and re-paginate from the start.
Reducing PACK frequency: PACK is necessary because FoxPro's soft-delete model
accumulates tombstoned rows. If a particular table grows large with deleted records, consider
whether the FoxPro code can reuse deleted slots (via RECALL + re-populate) rather
than always appending new rows. Ninja's own deletes also soft-delete — they are not reclaimed
until a manual PACK.
Assign Counter Ownership Clearly
Sequential document numbers — invoice numbers, batch IDs, check numbers, GL keys — are typically
stored in a central counter table (often appreg01.dbf). Both
FoxPro and Ninja may need to allocate new numbers. If both systems read and increment the same
counter row without coordination, they will issue duplicate numbers.
The principle: For each document type, designate one system as the allocator. The other system should either call into the allocator or accept numbers from the user rather than generating them independently.
Practical options:
- FoxPro allocates, Ninja accepts: The user enters or pastes the next number when creating a document through the Ninja UI. Ninja validates the number is not a duplicate but does not generate it. Simple and safe during a transition period.
- Ninja allocates via the counter registry: Configure Ninja to use
counters.backend: registry, which reads and writesappreg01.dbfwith row-level locking using the same locking protocol FoxPro uses. FoxPro's counter routine and Ninja will serialize naturally. - Partition by prefix: Give FoxPro all numbers in one range (e.g. 1–49999) and Ninja another (50000–99999). Simple to implement but visible in the data; also requires configuring FoxPro to stay in its range.
The default Ninja counter store (counters.backend: file) is an independent file that
does not coordinate with FoxPro's counter table. Enable the registry backend before
Ninja creates any documents that share a number sequence with FoxPro.
Always Use SET DELETED ON
FoxPro's default for a new session depends on the application's initialization code. Code written
without explicit SET DELETED ON at session start may see soft-deleted rows as if they
were live — an artifact of FoxPro's "the row is there, just flagged" model. This was rarely a
problem in single-user apps; with Ninja deleting rows through the API it becomes more visible.
What to do: Add SET DELETED ON to your application's startup
routine (typically the main program or environment setup screen). This makes FoxPro invisible
to deleted rows in all subsequent commands, matching the behavior Ninja already enforces on its
side.
Side effect to audit: Turning on SET DELETED ON in code that
previously ran without it may cause reports or queries to return fewer rows if deleted records
were being included in counts or aggregations. Review any code that sums, counts, or averages
over a table after making this change.
Ninja always filters soft-deleted rows — they never appear in API responses regardless of the FoxPro session setting. The recommendation here is to make FoxPro consistent with what the API exposes.
Audit DBC Triggers and Stored Procedures
If your data directory includes a Visual FoxPro Database Container (DBC), any stored procedures, triggers, or rules defined there will fire on writes from the FoxPro application. They do not fire on Ninja writes — Ninja reads the DBC metadata for field defaults and RI rules but executes those rules in Go, not by invoking the FoxPro runtime.
This asymmetry is intentional — FoxPro stored procedures can only run inside the FoxPro runtime — but it has practical implications:
- Audit triggers: If a DBC trigger sends an email, writes to a log table, or updates a summary table, those side effects will not happen on Ninja writes. Decide whether that behavior needs to be replicated in a Ninja aggregate.
- Validation rules: DBC field-level rules (the RULE clause on a field) are read
and enforced by Ninja's rule evaluator for supported expression shapes. If you have rules that use
complex FoxPro expressions, check
/health/details→unsupported_rulesat startup to see which ones Ninja cannot evaluate. Those rules will not block invalid writes from the API path. - Default values: DBC DEFAULT clauses for fields are applied by Ninja when
columns are omitted from an insert. Check
/health/details→unsupported_defaultsfor any expressions the engine cannot parse.
Ninja's evaluation of field rules and defaults is designed to handle the common patterns found in standard VFP applications. The Migration Toolkit can convert complex trigger logic into Ninja aggregate steps that run explicitly on each API write.
Be Aware of Long BROWSE Sessions
FoxPro's BROWSE command opens an interactive grid over a table. BROWSE holds the
table open in shared mode for its entire session and caches the row count at open time. During
a long BROWSE session:
- Rows appended by Ninja may not appear in BROWSE until the user scrolls past the original end-of-file or closes and reopens BROWSE.
- Rows deleted by Ninja will show as marked-deleted (strikethrough) if
SET DELETED OFFis in effect, or simply disappear ifSET DELETED ON. - BROWSE itself does not block Ninja — it holds a shared handle, not an exclusive lock — so API operations continue normally.
What to tell users: Close and reopen BROWSE (or press Ctrl+W and reopen) if the row count looks stale after the API has been active. This is a cosmetic issue, not a data integrity issue — the underlying DBF is correct.
Avoid leaving BROWSE open overnight on a table that Ninja writes frequently. The shared file handle is not harmful by itself, but users returning the next morning to a BROWSE showing yesterday's data can be confusing. Consider building your FoxPro UI to open BROWSE on demand and close it when not in use.
Migration Toolkit Overview
The Migration Toolkit (included in the Professional and Enterprise tiers) converts your existing FoxPro screens and programs into aggregate YAML definitions that the Ninja engine can execute. It is the fastest path from a working FoxPro application to a fully-capable REST API.
The toolkit pipeline:
- Scan — discovers all
.sc2(screen) and.prg(program) files in your FoxPro project. - Parse — a native tokenizer extracts the structure and field references from each file without executing any FoxPro code.
- Ground — every field reference is validated against your live DBF schema, eliminating hallucinated column names.
- Convert — an LLM generates a YAML aggregate draft for each screen or program, using a vocabulary constrained to the transforms and validators the engine supports.
- Validate — each draft is loaded through the engine's YAML validator. Drafts that fail validation are automatically repaired before being saved.
- Emit — drafts are written to a review directory for human approval before promotion to the active aggregates directory.
The toolkit sends FoxPro source code to an LLM API for conversion. This is disclosed before the first run and requires an explicit acknowledgment flag. Source code is not retained by the LLM provider beyond the request.
Running a Migration
# Dry run — analyze sources, report what would be converted, no LLM calls ninja.exe migrate --dry-run \ --source C:\FoxProApp\screens \ --data-dir C:\AccountingData\appdata \ --out C:\F8Labs\drafts # Full conversion run with a cost cap ninja.exe migrate \ --source C:\FoxProApp\screens \ --data-dir C:\AccountingData\appdata \ --out C:\F8Labs\drafts \ --max-cost 25.00
| Flag | Description |
|---|---|
--source | Directory containing .sc2 / .prg files. |
--data-dir | Your live DBF data directory (for schema grounding). |
--out | Output directory for generated YAML drafts. |
--dry-run | Analyze and report without making any LLM API calls or generating files. |
--max-cost | Maximum LLM API spend in USD. The run stops when this limit is reached. |
--no-cost-cap | Run without a cost limit. Must be specified explicitly if --max-cost is omitted. |
--include | Additional .prg files to bundle for cross-file class resolution. |
Reviewing Drafts
After the toolkit runs, review the YAML drafts in the output directory before promoting them to your active aggregates directory. Each draft file includes:
- The source file it was derived from
- A confidence score and any gaps the toolkit flagged
- The full YAML aggregate definition ready to load
Drafts that pass validation can be copied directly to the
aggregates_dir configured in your server config. The server picks
them up on the next restart (or hot-reload if enabled).
Use POST /aggregates/{name}/dry-run against a running server to test a promoted aggregate with real data before relying on it in production.