@volter/twin-airtable
v0.1.37
Published
Local Airtable twin - a faithful, stateful local Airtable Web API (/v0) your integration talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-airtable
Legacy connector helpers: this package still has callable helpers using the retired v1
syncPullAPI. Those paths require migration before use on the current kernel; older helper descriptions below do not establish current compatibility. Check the generated index for protocol standing and use the shared model for current state semantics.
The Airtable twin — a local, stateful, vendor-faithful replica of the
Airtable Web API (/v0) on the shared
@volter/world-core kernel. The unmodified airtable npm client — Airtable's own
official JS client — points at it through its own AIRTABLE_ENDPOINT_URL / endpointUrl knob and
gets vendor-correct responses.
State lives in the kernel's append-only action/event log — a write appends an action that projects into visible state; reads use the kernel’s tree. No real Airtable is ever called (the only network path is the connector, over an injected client, behind a fail-closed rate budget). Auth is a faked-locally Bearer personal access token — any value is accepted and none is verified, so the twin serves no 401/403.
Usage
bun run src/cli.ts serve # the Airtable Web API twin (writable)
bun run src/cli.ts serve --read-only
bun run src/cli.ts mirror # the React base-browser mirror (bases -> tables -> records)
bun run src/cli.ts conformance # offline endpoint-probe check over the real routerPoint an unmodified client at it with the variable Airtable's own client reads:
AIRTABLE_ENDPOINT_URL=http://127.0.0.1:<port> AIRTABLE_API_KEY=patAnythingLocal node app.jsNothing is implicit. A fresh twin has no bases, so GET /v0/{baseId}/{table} 404s exactly as the
vendor does for a base you cannot see. State is created through the vendor's own metadata API —
POST /v0/meta/bases (base + tables + fields) and POST /v0/meta/bases/{baseId}/tables — so there
are no twin-only seed routes at all.
Coverage
The capability manifest (src/airtable-capabilities.ts) is the real Airtable Web API surface as
the denominator, enumerated top-down from Airtable's own OpenAPI document (103 operations across
22 tags; see src/airtable-spec.ts for the provenance and read date). Coverage is honest and
partial. Run bun scripts/manifest-baseline-one.ts airtable for the live count.
Modeled (proven done, each with a failable offline verify):
- Records — list / get / create (single + batch) / update (
PATCHmerge,PUTdestructive, single + batch) / upsert (performUpsert.fieldsToMergeOn, withcreatedRecords/updatedRecords) / delete (single + batch),POST …/listRecords, and the vendor's{id, createdTime, fields}shape. - List parameters —
pageSize(capped at the documented 100), the opaqueoffsetcursor,maxRecords,fields[],sort[i][field|direction],view,cellFormat=json|string(with the documentedtimeZone/userLocalerequirement),returnFieldsByFieldId,recordMetadata[]=commentCount. filterByFormula— a REAL evaluator over a stated subset (comparisons,AND/OR/NOT,LOWER/UPPER/TRIM/LEN/FIND,TRUE/FALSE/BLANK,{Field}and bare field refs, the vendor's own truthiness rule). See the honesty note below.- Field types + typecast — cells validated against Airtable's closed 34-value
Field-Typeenum; strict by default, best-effort string conversion undertypecast: true; computed fields (formula/rollup/lookup/…) refuse writes. - Metadata API —
GET /v0/meta/whoami, list bases, create base, base schema, create/update table, create/update field. Cells are stored by field ID, so a field rename never loses a value. - Comments — list / create / update / delete, threaded replies via
parentCommentId. - Protocol — Airtable's
{"error": {"type", "message"}}envelope with only the documented type strings,404 NOT_FOUNDfor unmodeled ops and unknown bases/tables/records,422 INVALID_REQUEST_UNKNOWN(with the vendor's verbatim message) for parameter validation, read-only rejects writes. - Dirty-state invariants — a deleted record's id is never reissued, and setting a cell back to a value it previously held is a real write rather than a deduped replay.
- Connector — pull bases + schemas + records (idempotent, cursor-following, moving poll
timestamp; a 4xx, a 200-with-error-envelope, and a 200 whose collection array is missing are all
REFUSALS that throw rather than folding an empty account over real state). Push confirms only on
success and is deliberately narrower than pull: only RECORD writes are pushable, and an update or
delete only for a record whose id the vendor issued — a record this twin minted is never
addressed by that id, not even after its create push was confirmed, because confirming a create
does not make the local id real. A record created and then deleted locally is never materialized
in the real account at all, and a destructive local
PUTis pushed as aPUTrather than flattened to aPATCH. Reconciling the vendor-minted id back onto a locally created record is a filed gap (airtable.connector.push_id_reconciliation), and it is what would widen this. - Rate budget — the live connector path refuses past a ceiling grounded in Airtable's published 5 requests/second per base, proven by counting calls against an injected fake.
- UI mirror — the bases sidebar, the tables strip, the schema chips and the records grid are all
data-coupled (seed through the twin → fetch the same vendor path the screen reads → render the
mirror's own exported component → assert the seeded values in the emitted HTML). The screen shows
real twin state or says why it cannot: a refusal renders a visible band carrying the twin's own
status and error type rather than an empty placeholder, and a grid showing only the first page
says so instead of presenting it as the whole table. The mirror port is the same handler as the
API port — same world clock, same
readOnly, same contained errors.
Todo (real surface, not yet modeled): webhooks (create/list/delete/payloads/refresh/MAC
signing), views API (list/get/delete, per-view metadata on a pulled base, and a view's filters
actually shaping ?view=), attachment
upload, CSV sync, date-dependency metadata, computed-field evaluation, linked-record integrity,
workspaces, collaborators/invites/shares, block installations, the enterprise/SCIM/audit-log/
change-event/hyperDB/automation-run families, personal-access-token scope enforcement, and the
expanded-record / cell-editing / view-switcher / grid-pagination mirror screens.
Three honesty notes
There is no invented batch-size cap. Airtable's live API is widely reported to reject more than
10 record objects per write. As of 2026-08-31 neither first-party source states it — the OpenAPI
document declares no maxItems anywhere, and the create/update/delete-multiple-records pages state
no maximum. Serving a rejection the vendor's published contract does not describe would be surface
Airtable does not have, so the twin does not enforce one; the gap is filed as
airtable.record.batch_size_limit.
The twin never serves a 429. Airtable's limit is 5 requests/second — a wall-clock property, and
a twin's served response must be a pure function of (request, stored state). The vendor's rate
behavior is modeled where it actually protects someone: the client-side budget on the live connector
path (src/airtable-budget.ts), whose ceiling is half Airtable's documented per-base allowance.
filterByFormula refuses what it cannot evaluate. Airtable's formula language is the full
field-formula language (~100 functions, dates, regex, arithmetic). The twin implements a real
evaluator for the subset documented in src/airtable-formula.ts and answers 422 with a [twin
gap] message for anything else. The alternative — ignoring the parameter — would return every
record, which a caller reads as "nothing matched your filter": the worst lie available here. The
refusal is a property of the FORMULA, decided at parse time: it does not depend on whether any
record happened to be iterated, and a whitespace-only formula reaches the parser rather than being
skipped. Which 422 you get is itself a claim about whose fault it is: an unmodeled construct
(REGEX_MATCH, arithmetic, a wrong arity) is the twin's gap and says [twin gap], while a
malformed formula or a reference to a field the table does not have is a rejection real Airtable
also gives and carries the vendor's own documented message — blaming the twin for the caller's bad
input would be its own small lie.
Planned (todo):
- Attachment bytes — persist uploaded attachment bytes and serve them at a twin-local URL that resolves, with the documented expiry semantics (same for comment attachments).
- 401 on a non-matching token — refuse a Bearer token that does not match the world's established personal access token; the twin accepts any value today.
Mirror
Airtable is a vendor people work in — the grid is the product — so this pack ships a mirror. It
is archetype A (API passthrough): the mirror server's non-asset requests fall through to the same
handleAirtableTwinRequest the API uses, and the React client fetches the vendor's real paths
(/v0/meta/bases, /v0/meta/bases/{id}/tables, /v0/{baseId}/{tableId}) on the same origin. There
is one code path, so API↔UI parity cannot drift.
The mirror is currently a read-only state visualization of bases, schemas and records, while
Airtable's real product UI (grid editing, view configuration, interface designer, automations) is far
beyond what it models. ui-scope.json records needsUi: true anyway — Airtable's grid is the
product, so an agent standing in for a human working in a base is a real scenario, and grading the
census answer on the mirror's current capability would justify needsUi: false for any pack whose
mirror happens to be read-only. The two journeys the UI must eventually support are therefore filed
as journey debt (a WARN, not a green), alongside the airtable.mirror.cell_editing,
airtable.mirror.record_expand and airtable.mirror.url_routing manifest todos they are blocked on
— the same shape notion, mixpanel and segment carry.
