FiniDB agent guide
This text is also the MCP resource finidb://guide/full, finidb guide --full and GUIDE.md in the npm package; the short version is the package README (finidb guide, /agent).
1. What FiniDB is
FiniDB is a modeling database for agents: named tables, pivot tables (a grid of line items × periods with formulas placed by condition), and methods, formulas attached to a condition instead of to a cell.
A changed input recomputes only its dependents, in milliseconds, even with 100,000-row fact tables; every cell can explain which method and inputs produced it.
It runs embedded (npx finidb … against a directory), as a daemon (finidb serve), or hosted at finicast.com, with the same commands.
The model is one reviewable file: finidb apply model.json builds it, export script writes it back.
Why not a spreadsheet: =SUMIFS($C:$C,$A:$A,$A2,$B:$B,B$1) copied over 720 cells is one method here, frame = hist: SUM(SELECT("amount","ledger","account","=",THIS("account"),"period","=",THIS("period"))); no coordinates, no copy-down, no ranges that break when a row is added.
2. Workflow and concepts
Write the whole model as one script, apply it, look, verify, deliver. apply is idempotent: edit the file and apply again after every change; it updates what differs (structure, methods, rows) and reports the diff.
npx finidb apply model.json --data ./model # build or update the model from one file (--dry-run previews)
npx finidb query ./model pl # render a pivot as markdown (--cdim <id> for another value dim)
npx finidb errors ./model # must print "no errors" before you report done
npx finidb formula explain ./model pl value 8 # which method and inputs made cell 8 (row * columns + column)
npx finidb export ./model xlsx --out model.xlsx # the deliverable; export ./model script --model-script writes the file back
MCP: finidb_apply {script} or {path}, finidb_render, finidb_list_errors, finidb_explain, finidb_query (ad-hoc formulas), finidb_export. finidb_commands (a POST /commands batch, §3) is the incremental path for one-off edits and for adding rows to a live database; finidb_describe first on a database you did not build.
Database › Model m1
ledger (tabular) id, account→accounts, period→periods, amount fact rows
accounts (tabular) id, Name periods (tabular) id, Name, Frame (hist|fcst)
pl (pivot) vdimIds [account] hdimIds [period] cdimIds [value]
linked pdim frame ← periods.Frame periodsDimId period
methods, last match wins: frame = hist → SUM(SELECT(…)); account = gp → 'rev' - 'cogs'
- Model: a namespace with an ordered list of tables; one per database is normal.
- Table:
id, name, dimIds, isPivot, methods[], condObjs[], views[]. Ids:[0-9A-Za-z_ ,.()$&%#], 2–64 chars. - Dim: a column;
dimIds[0]is alwaysid. Kinds: plain, reference (refModelId, refTableId, refDimId: values are ids of another table), linked pdim, cdim (pivot value dim). Types:id, string, number, decimal, date, boolean, formula, reference. - Record: a row of a tabular table addressed by
id(auto-generated when omitted). Rows align withdimIdsunless adimIdsheader names the columns (§7). - Pivot: no stored records.
vdimIds(rows, outer→inner) andhdimIds(columns) are reference dims whose members come from the reference table in record order;cdimIdsare value dims; frames (vfdimIds/hfdimIds) show one value per row/column. - Linked pdim
{dimId, linkedToPdimId, fetchDimId}: fetches a column of a pdim's reference table for the current member, auto-placed in the frames.Framefromperiodsis the hist/fcst switch; aparentcolumn gives levels. - Method
{name, dimId, condition, formula}: applies to every cell ofdimIdwhose record/member tuple matchescondition(empty = all). Ordered; last matching wins. A condition may not test the method's own dim. - Cond_obj
{type, name, dimId, condition, data},type ∈ style, format, validation, task, comment, censor. View: saved render settings{id, name, isDefault, spec}. Level: a child pdim plus a linked pdim fetching its parent column; collapsing hides descendants, parents are ordinary members with a method.
Cell precedence: per-cell formula (=…) > entered value > last matching method > blank.
2.1 The model script
One JSON (or YAML) file {models, tables, methods, data} (§5.1 is a complete one). apply diffs it against the database: create-or-update, --dry-run validates and plans exactly like the real run, --prune also removes what the file omits. Unknown keys are rejected with their path (tables[0].periodsDimId (did you mean pivot.periodsDimId?)).
- A table is
{modelId, id, name?, isPivot?, dims[], pivot?, methods?, views?}; a dim is{id, type},{id, refModelId, refTableId, refDimId}or a linked pdim{id, linkedToPdimId, fetchDimId};pivotis{vdimIds, hdimIds, cdimIds, periodsDimId}(vdimIdsmust not be empty). methodsis a list of{modelId, tableId, name, dimId, condition, formula}at the root, or inside a table withoutmodelId/tableId.datakeys aremodel:table; a value is{dimIds, records}naming each value's column (preferred), or bare rows aligned with[id, …dims]in the table's order. Rows whose id exists are updated, new ids are created. Pivot cells ({"dimIds":["dept","month","headcount"],"records":[["eng","2026-01",10]]}) may sit in the same file that creates the pivot: they are applied after the structure and the reference rows.- Bulk facts load faster with
finidb import csv <dir> facts.csv --table ledger(first column = id, unique; types inferred,--types date:date). - Every id and method name is 2–64 characters; ids are case-sensitive.
finidb export <dir> script --model-scriptwrites any database back in this form: the quickest way to see the shape.
3. The five commands you need
Send commands as a batch to POST /commands (MCP finidb_commands; atomic: a bad step rolls back the batch and names the step). Paths are relative to the database base URL (https://finicast.com/db/<project> hosted, http://localhost:7407/v1/db/<db> local daemon).
[
{"type":"CREATE_TABLE","data":{"modelId":"m1","id":"periods","name":"Periods"}},
{"type":"CREATE_DIM","data":{"modelId":"m1","tableId":"periods","id":"Frame","type":"string"}},
{"type":"CREATE_MANY","data":{"modelId":"m1","tableId":"periods","records":[["2026-01","hist"],["2026-02","fcst"]]}},
{"type":"SET_PIVOT","data":{"modelId":"m1","tableId":"pl","vdimIds":["account"],"hdimIds":["period"],"cdimIds":["value"]}},
{"type":"CREATE_METHOD","data":{"modelId":"m1","tableId":"pl","name":"fcst","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"fcst"}],"formula":"PREV(\"value\") * 1.05"}}
]
CREATE_MODEL {id, name}first;CREATE_TABLE {modelId, id, name?, isPivot?};CREATE_DIM {modelId, tableId, id, type?, refModelId?, refTableId?, refDimId?}.CREATE_MANY {modelId, tableId, dimIds?, records, dupBehavior?}: prefer thedimIdsheader, naming the column of each row value in any order ({"dimIds":["id","rate"],"records":[["ops",0.3]]}; omitted dims stay blank,idis generated).dupBehaviorcancel(default) |skip|update.SET_PIVOT {modelId, tableId, vdimIds, hdimIds, cdimIds};SET_PERIODS_DIM_ID {modelId, tableId, periodsDimId};CREATE_DIM_LINKED_TO_PDIM {modelId, tableId, dimId, linkedToPdimId, fetchDimId}.CREATE_METHOD {modelId, tableId, name, dimId, condition, formula};UPDATE_METHOD {methodIdx, …},MOVE_METHOD {fromIdx, destIdx}.SET_VALUE {modelId, tableId, dimId, recordId|recordIdx|coords, value},UNSET_VALUEand the batchSET_VALUES {values:[{dimId, …, value}]}(MCPfinidb_set_values): address a row byrecordIdorrecordIdx, a pivot cell bycoords: {pdimId: memberId, …}naming every pdim (§5.3) or byrecordIdx = row * columns + columnover the members (0-based;columns= column-axis members) — exactly one of the three;nullclears.REMOVE_TABLE {…, confirm:true}. The REST resources (§8) take the samedataobjects one at a time.
4. Formulas
THIS("dim"): tabular → another column of the current record; pivot → a pdim's member id, a linked pdim's value (THIS("frame")), or another cdim of the same cell.SELECT("dim","table", "d1","=",v1, "d2",">=",v2, …): values ofdimfor the rows oftablematching every condition. As a scalar: one match → the value, none → blank, many →#VALUE!. Fused aggregates (one incremental operator):SUM, COUNT, COUNTA, COUNTBLANK, AVERAGE, MIN, MAX, MEDIAN, FIRST, LAST, LISTAGG, RANK.LOOKUP("dim","table","key_dim", key): scalarSELECTwith one equality, e.g.LOOKUP("value","assumptions","id","growth").PTHIS("dim","member", …): a cell of the same pivot by member ids; unspecified pdims stay current.PREV("cdim"[, n])/NEXT: same row/column,nperiods earlier/later alongperiodsDimId;PPRIOR("pdim", offset)the same by explicit pdim. Out of range → blank, noIFERRORneeded.CUMULATIVE("member", offset),YTD("member"): running sums along the periods axis.ALL("pdim"): every member, forSUM(ALL("account"))totals.PERIOD(date, "periods"): the id of the period containingdate(start/endcolumns, else idsYYYY-MM,YYYY-Qn,YYYY).'term'syntax:'rev' - 'cogs'reads membersrevandcogsof the line-item pdim;'prior rev'steps one period back.- Rounding:
INT(x)floors toward −∞ (INT(-3.7)= −4);ROUNDDOWN(x, 0)andTRUNCtruncate toward zero; alsoROUND,ROUNDUP,MROUND. - Conditions:
[{left, comparison, right, join}],comparison ∈ = <> < <= > >=,joinAND(default) |OR; AND binds tighter than OR;rightis a literal, compared case-sensitively. - Blank is 0 in
+ - SUM, skipped byCOUNT/COUNTA/AVERAGE/MIN/MAX/MEDIAN; a lookup with no match is blank, never an error (IFBLANK(v, alt)). - Errors are values that propagate:
#DIV/0!,#REF!,#NAME?,#VALUE!,#CIRC!;IFERROR(v, alt),ISERROR(v).
Restrictions: R1 table/model/dim names in SELECT, THIS, PTHIS, PPRIOR, PREF, ALL, CUMULATIVE, LOOKUP, PERIOD are string literals. R2 a nested SELECT in a condition value is allowed; scalar equality forms stay incremental, other forms make the rule coarse. R3 no EVAL. R4 a cycle is an error, except time recursion: a dim reading itself through PREV/NEXT/PPRIOR/CUMULATIVE, and cdims of one pivot whose only back-edges are such shifts of each other (a period group, settled period by period, §5.3). Same-period cycles and shifts pointing both ways (a self PREV fed by a sibling's NEXT) are refused. R5 RAND, RANDBETWEEN, TODAY recompute only on explicit recompute.
5. Recipes
Each recipe is one model script for an empty directory: save it as model.json, npx finidb apply model.json --data ./model, then npx finidb query ./model <pivot>. 5.2–5.4 omit "models":[{"id":"m1"}] for brevity; add it.
5.1 Income statement with hist/fcst frames and growth assumptions
{"models":[{"id":"m1"}],
"tables":[
{"modelId":"m1","id":"periods","dims":[{"id":"Frame","type":"string"}]},
{"modelId":"m1","id":"accounts","dims":[{"id":"Name","type":"string"}]},
{"modelId":"m1","id":"assumptions","dims":[{"id":"value","type":"number"}]},
{"modelId":"m1","id":"ledger","dims":[
{"id":"account","refModelId":"m1","refTableId":"accounts","refDimId":"id"},
{"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
{"id":"amount","type":"number"}]},
{"modelId":"m1","id":"pl","isPivot":true,"dims":[
{"id":"account","refModelId":"m1","refTableId":"accounts","refDimId":"id"},
{"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
{"id":"value","type":"number"},
{"id":"frame","linkedToPdimId":"period","fetchDimId":"Frame"}],
"pivot":{"vdimIds":["account"],"hdimIds":["period"],"cdimIds":["value"],"periodsDimId":"period"}}],
"methods":[
{"modelId":"m1","tableId":"pl","name":"hist","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"hist"}],
"formula":"SUM(SELECT(\"amount\",\"ledger\",\"account\",\"=\",THIS(\"account\"),\"period\",\"=\",THIS(\"period\")))"},
{"modelId":"m1","tableId":"pl","name":"fcst rev","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"fcst"},{"left":"account","comparison":"=","right":"rev"}],
"formula":"PREV(\"value\") * (1 + LOOKUP(\"value\",\"assumptions\",\"id\",\"growth\"))"},
{"modelId":"m1","tableId":"pl","name":"fcst cogs","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"fcst"},{"left":"account","comparison":"=","right":"cogs"}],
"formula":"PTHIS(\"account\",\"rev\") * LOOKUP(\"value\",\"assumptions\",\"id\",\"cogs_pct\")"},
{"modelId":"m1","tableId":"pl","name":"gp","dimId":"value","condition":[{"left":"account","comparison":"=","right":"gp"}],"formula":"'rev' - 'cogs'"}],
"data":{
"m1:periods":[["2026-01","hist"],["2026-02","hist"],["2026-03","fcst"],["2026-04","fcst"]],
"m1:accounts":[["rev","Revenue"],["cogs","COGS"],["gp","Gross profit"]],
"m1:assumptions":[["growth",0.05],["cogs_pct",0.4]],
"m1:ledger":{"dimIds":["id","account","period","amount"],"records":[["l1","rev","2026-01",1000],["l2","cogs","2026-01",400],["l3","rev","2026-02",1100],["l4","cogs","2026-02",430]]}}}
Verify: query ./model pl → rev = 1000, 1100, 1155, 1212.75; gp / 2026-03 = 693. Reconcile rev / 2026-01 with finidb_query SUM(SELECT("amount","ledger","account","=","rev","period","=","2026-01")) = 1000. What-if: change growth to 0.1 in the file and re-apply (or SET_VALUES {"values":[{"dimId":"value","recordId":"growth","value":0.1}]}); only fcst cells change.
5.2 Sales-ops activity scoring (CSV → territory pivot)
Benchmark shape: 12 periods, 200 reps, 100,000 activities. Computed columns are methods on the fact table; the rows come from finidb import csv ./model activities.csv --table activities (id, rep_id, type, date; the id column must be unique) or, as here, from data.
{"tables":[
{"modelId":"m1","id":"periods","dims":[{"id":"Frame","type":"string"}]},
{"modelId":"m1","id":"reps","dims":[]},
{"modelId":"m1","id":"territory_list","dims":[]},
{"modelId":"m1","id":"territories","dims":[{"id":"rep_id","refModelId":"m1","refTableId":"reps","refDimId":"id"},{"id":"territory_id","type":"string"}]},
{"modelId":"m1","id":"scoring","dims":[{"id":"type","type":"string"},{"id":"score","type":"number"}]},
{"modelId":"m1","id":"activities","dims":[
{"id":"rep_id","refModelId":"m1","refTableId":"reps","refDimId":"id"},{"id":"type","type":"string"},{"id":"date","type":"date"},
{"id":"period","type":"string"},{"id":"score","type":"number"},{"id":"territory","type":"string"}],
"methods":[
{"name":"period","dimId":"period","condition":[],"formula":"PERIOD(THIS(\"date\"),\"periods\")"},
{"name":"score","dimId":"score","condition":[],"formula":"SELECT(\"score\",\"scoring\",\"type\",\"=\",THIS(\"type\"))"},
{"name":"territory","dimId":"territory","condition":[],"formula":"SELECT(\"territory_id\",\"territories\",\"rep_id\",\"=\",THIS(\"rep_id\"))"}]},
{"modelId":"m1","id":"territory_scores","isPivot":true,"dims":[
{"id":"territory_id","refModelId":"m1","refTableId":"territory_list","refDimId":"id"},
{"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
{"id":"score","type":"number"},{"id":"score_fcst","type":"number"},
{"id":"frame","linkedToPdimId":"period","fetchDimId":"Frame"}],
"pivot":{"vdimIds":["territory_id"],"hdimIds":["period"],"cdimIds":["score","score_fcst"],"periodsDimId":"period"},
"methods":[
{"name":"score","dimId":"score","condition":[],"formula":"SUM(SELECT(\"score\",\"activities\",\"territory\",\"=\",THIS(\"territory_id\"),\"period\",\"=\",THIS(\"period\")))"},
{"name":"hist","dimId":"score_fcst","condition":[{"left":"frame","comparison":"=","right":"hist"}],"formula":"THIS(\"score\")"},
{"name":"fcst","dimId":"score_fcst","condition":[{"left":"frame","comparison":"=","right":"fcst"}],"formula":"PREV(\"score_fcst\") * 1.05"}]}],
"data":{
"m1:periods":[["2026-01","hist"],["2026-02","hist"],["2026-03","fcst"],["2026-04","fcst"]],
"m1:reps":[["r1"],["r2"]],
"m1:territory_list":[["west"],["east"]],
"m1:territories":[["t1","r1","west"],["t2","r2","east"]],
"m1:scoring":[["s1","call",1],["s2","email",0.5],["s3","demo",5]],
"m1:activities":{"dimIds":["id","rep_id","type","date"],"records":[["a1","r1","call","2026-01-05"],["a2","r1","demo","2026-01-20"],["a3","r2","email","2026-02-02"]]}}}
The activity rows name their columns with dimIds because the table's last three dims are computed: a positional row ["a1","r1","call","2026-01-05",…] would be fine too, but one with a fifth value would override the period method for that row. Verify: west / 2026-01 = 6 (call 1 + demo 5); the pivot has two value dims, query --cdim score_fcst shows the other. Reconcile SUM(SELECT("score","activities","period","=","2026-01")) with the column total.
5.3 Headcount and payroll plan by department × month
{"tables":[
{"modelId":"m1","id":"months","dims":[{"id":"Frame","type":"string"}]},
{"modelId":"m1","id":"departments","dims":[{"id":"salary","type":"number"},{"id":"start","type":"number"},{"id":"rate","type":"number"}]},
{"modelId":"m1","id":"hiring","dims":[{"id":"dept","refModelId":"m1","refTableId":"departments","refDimId":"id"},{"id":"month","refModelId":"m1","refTableId":"months","refDimId":"id"},{"id":"hires","type":"number"}]},
{"modelId":"m1","id":"plan","isPivot":true,"dims":[
{"id":"dept","refModelId":"m1","refTableId":"departments","refDimId":"id"},
{"id":"month","refModelId":"m1","refTableId":"months","refDimId":"id"},
{"id":"headcount","type":"number"},{"id":"hires","type":"number"},{"id":"attrition","type":"number"},{"id":"payroll","type":"number"},
{"id":"frame","linkedToPdimId":"month","fetchDimId":"Frame"}],
"pivot":{"vdimIds":["dept"],"hdimIds":["month"],"cdimIds":["headcount","hires","attrition","payroll"],"periodsDimId":"month"},
"methods":[
{"name":"hires","dimId":"hires","condition":[],"formula":"SUM(SELECT(\"hires\",\"hiring\",\"dept\",\"=\",THIS(\"dept\"),\"month\",\"=\",THIS(\"month\")))"},
{"name":"opening","dimId":"headcount","condition":[{"left":"frame","comparison":"=","right":"hist"}],"formula":"LOOKUP(\"start\",\"departments\",\"id\",THIS(\"dept\"))"},
{"name":"roll forward","dimId":"headcount","condition":[{"left":"frame","comparison":"=","right":"fcst"}],
"formula":"PREV(\"headcount\") + THIS(\"hires\") - THIS(\"attrition\")"},
{"name":"attrition","dimId":"attrition","condition":[{"left":"frame","comparison":"=","right":"fcst"}],"formula":"INT(PREV(\"headcount\") * LOOKUP(\"rate\",\"departments\",\"id\",THIS(\"dept\")))"},
{"name":"payroll","dimId":"payroll","condition":[],"formula":"THIS(\"headcount\") * LOOKUP(\"salary\",\"departments\",\"id\",THIS(\"dept\"))"}]}],
"data":{
"m1:months":[["2026-01","hist"],["2026-02","fcst"],["2026-03","fcst"]],
"m1:departments":[["eng",9000,10,0.1],["sales",6000,4,0.2]],
"m1:hiring":[["h1","eng","2026-02",2]]}}
headcount and attrition read each other, but only backwards in time, so they form a period group settled month by month (R4); no need to fold attrition into the headcount rule. Verify: eng headcount is 10, 11, 10; attrition (--cdim attrition) 1 for eng in both fcst months; payroll eng / 2026-02 = 99000. Reconcile SUM(ALL("dept")) on payroll / 2026-01 = 114000. What-if without touching the file: SET_VALUES {"values":[{"dimId":"hires","coords":{"dept":"sales","month":"2026-02"},"value":1}]} enters one hire for sales / 2026-02; an entered value beats the hires method for that cell.
5.4 Cohort retention
{"tables":[
{"modelId":"m1","id":"periods","dims":[]},
{"modelId":"m1","id":"cohorts","dims":[{"id":"start","refModelId":"m1","refTableId":"periods","refDimId":"id"},{"id":"size","type":"number"}]},
{"modelId":"m1","id":"rates","dims":[{"id":"name","type":"string"},{"id":"value","type":"number"}]},
{"modelId":"m1","id":"retention","isPivot":true,"dims":[
{"id":"cohort","refModelId":"m1","refTableId":"cohorts","refDimId":"id"},
{"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
{"id":"users","type":"number"},
{"id":"start","linkedToPdimId":"cohort","fetchDimId":"start"}],
"pivot":{"vdimIds":["cohort"],"hdimIds":["period"],"cdimIds":["users"],"periodsDimId":"period"},
"methods":[{"name":"decay","dimId":"users","condition":[],
"formula":"IF(THIS(\"period\") < THIS(\"start\"), \"\", IF(THIS(\"period\") = THIS(\"start\"), LOOKUP(\"size\",\"cohorts\",\"id\",THIS(\"cohort\")), PPRIOR(\"period\", -1) * LOOKUP(\"value\",\"rates\",\"name\",\"retention\")))"}]}],
"data":{
"m1:periods":[["2026-01"],["2026-02"],["2026-03"],["2026-04"]],
"m1:cohorts":[["c1","2026-01",1000],["c2","2026-02",800]],
"m1:rates":[["r1","retention",0.8]]}}
PPRIOR("period", -1) reads this cdim one period back; before the first period it is blank, not an error. Verify: c1 = 1000, 800, 640, 512; c2 = blank, 800, 640, 512; SUM(ALL("cohort")) at 2026-03 = 1280. Age-dependent rates: an ages table with ids 1, 2, … and LOOKUP("rate","ages","id",MIN(age, 4)); numeric-looking ids are numbers (§7).
5.5 Financial statements from Financial Modeling Prep (hosted only)
Hosted only. finicast_import_fmp {ticker, statements:["income","balance","cashflow"], period:"annual"|"quarter", forecastPeriods?} creates periods (Frame = hist, plus fcst periods on request), accounts, the fact table financials (account, period, amount) and a pivot statement with the frame = hist method SUM(SELECT("amount","financials","account","=",THIS("account"),"period","=",THIS("period"))). Add frame = fcst methods as in 5.1; verify one rendered total against the filing.
6. Verification
GET /errors(finidb_list_errors,finidb errors <dir>): every cell or rule in error, with hint. Must be empty before you report done.POST /models/m1/tables/pl/cells/explain {"dimId":"value","recordIdx":11}(finidb_explain,finidb formula explain <dir> pl value 11) → coordinates, owning method, formula, read set with values,incrementalor the coarse reason.- Reconcile with an ad-hoc formula (
finidb_query):SUM(SELECT("amount","ledger","period","=","2026-01"))against the pivot column total;SUM(ALL("account"))on a total row against its children.formula parseonly parses;finidb_queryover--urlruns the formula as a throw-away method in one batch. GET /stats(finidb_stats): timings, sizes and the coarse rules to rewrite.- Reading a render back: a pivot grid has one header row per linked pdim on the column axis, then the member-id row; each body row starts with one column per linked pdim on the row axis, then the member id. Locate a row by its id, not by position.
7. Pitfalls
- Method order: the last matching method wins; general rule first, overrides after (
MOVE_METHODfixes precedence). - Ids vs names: conditions and
PTHISuse member ids (rev), never display names;THIS("account")on a pivot is the id. - Bare rows are positional: without a
dimIdsheader,datarows andCREATE_MANYrecords line up with the table's dims in order,idfirst, computed dims included — a value on a computed column is kept as an entered value that overrides the method for that row, and a short row leaves the last columns blank. Use{dimIds, records}in both;import csvaligns by header. - Numbers everywhere: numeric-looking text becomes a number, ids included (
1→ 1,2026-01stays text;'1forces text), so compare CSV ids1, 2, …with1, not"1". - CSV import: the first column is the record id and must be unique (
record id "eng" appears twice in the batch); add anidcolumn to fact files that have none. - Blank vs 0: blank is 0 in arithmetic but skipped by
AVERAGE/COUNT. Dates: give date dimstype: "date"orPERIODfails. - Case-sensitive strings:
hist≠Hist; spellFramevalues consistently. - Cycles: same-period cycles are refused; dims of one pivot that read each other only through period shifts are settled period by period (§5.3).
INT(x)floors toward −∞,ROUNDDOWN/TRUNCtoward zero. - Render cap: a page is at most 200,000 cells (
CALCULATION_LIMIT); window large tables withstartRow/endRow/startCol/endCol(finidb_renderdefaults to 50 rows × 24 columns). - Coarse rules: a list-valued nested
SELECTin a condition disables incremental recalculation for that rule;statslists them. - Names: database names
[a-z0-9_]{2,64}; table/dim ids[0-9A-Za-z_ ,.()$&%#], never:. Ids are 2–64 characters, method names too ("id":"m"fails withMODEL_ID_INVALID). A pivot needs at least onevdimIdsentry. - Destructive commands (
REMOVE_TABLE,REMOVE_DIM,CLEAR_RECORDSon > 1,000 rows, drop database) requireconfirm: true. - Pivot cells in
datamay come in the sameapplythat creates the pivot; a cell already holding that entered value is skipped, so re-applying is a no-op.
8. API cheat sheet
| MCP tool | REST (relative to the database base URL) |
|---|---|
finidb_apply |
POST /apply (the model script; dryRun, prune) |
finidb_describe |
GET /models, GET /models/{m}/tables/{t} |
finidb_commands |
POST /commands (batch, atomic) |
finidb_create_table / finidb_create_pivot |
POST /models/{m}/tables, POST …/tables/{t}/dims, PUT …/tables/{t}/pivot, POST …/tables/{t}/linked-pdims |
finidb_add_method / finidb_set_values |
POST …/tables/{t}/methods / PUT …/tables/{t}/cells |
finidb_import_csv |
POST /import/csv?modelId=&tableId= |
finidb_render |
POST …/tables/{t}/render; POST /render (multi-table) |
finidb_query |
no REST route: a scratch method in one POST /commands batch |
finidb_explain |
POST …/tables/{t}/cells/explain `{dimId, recordId |
finidb_parse_formula |
POST /formulas/parse |
finidb_list_errors |
GET /errors |
finidb_export |
GET /export/xlsx, GET /export/csv?tableId=, GET /export/script |
finidb_stats |
GET /stats |
finidb_guide / finidb_functions |
/guide / /docs/functions on finicast.com (resources finidb://guide, finidb://functions) |
| change feed | GET /events?tables=m1:pl (SSE) |
Every command that names a row (SET_VALUE(S), UNSET_VALUE, UPDATE_RECORD, REMOVE_RECORD, MOVE_RECORD, explain) takes exactly one of recordId, recordIdx or, for a pivot cell, coords; an unknown id names the table and the nearest existing id.
Run locally
npm install -g finidb
finidb serve --data ~/finidb-data --port 7407 # daemon; superuser password printed once
finidb createdb salesops --owner alice
finidb mcp --url http://localhost:7407 --db salesops --user alice
Embedded mode needs none of this: npx finidb mcp --data ./model or the CLI path in §2.
Benchmarks
Engine-time targets from the benchmark suite (finidb/bench, B1: 100,000 activity rows, 20 × 12 territory pivot): one input change ≤ 5 ms (expected < 1 ms); a change in scoring touching ~16,700 rows ≤ 25 ms; editing a pivot formula ≤ 100 ms; initial load with the 100,000-row import ≤ 1,500 ms; pivot page render ≤ 3 ms. Every cell is checked against a brute-force recomputation after each step. Numbers on finicast.com are these results, nothing else.