The Local State Behind a Live API
Corsair’s most consequential design choice is the separation between a live provider operation and a local database read. The focus here is not just where data is stored, but how an API response becomes application state, when that state is useful, and when it must be checked against the provider again. Understanding that flow lets a builder choose .api or .db deliberately for a page, a mutation, a refresh button, or a knowledge-base query.
Two paths, two kinds of truth
A dashboard usually has two very different needs. It must render quickly and repeatedly, often for many visitors or many page transitions. It must also perform actions that change the external service. Asking the provider to answer every page load mixes those needs: a slow provider makes the page slow, a rate limit becomes a UI failure, and every feature must learn the provider’s response format. Keeping every value only in a local cache creates the opposite problem: a mutation may not reach the provider, and the local copy can quietly diverge.
Corsair separates those responsibilities into two namespaces. The .api path calls the live third-party service. It is the right path for creating, updating, or deleting a resource, and for a read where the application needs the provider’s latest value immediately. The .db path reads the representation Corsair has already stored in the application database. It is the right path for lists, search, detail pages, dashboard rendering, and retrieval that should not make a network request on every question.
The split is not a choice between “real data” and “fake data.” It is a choice between a live operation and a maintained projection. After a successful API call, Corsair can upsert the returned resource into its local entity state. The application can then read that state through .db. When a supported webhook announces a later change, Corsair updates the same entity row. A refresh therefore has a predictable shape: call .api to ask the provider for current data, then read .db to render the result through the same path used by ordinary page loads.
Consider a support product that links an application ticket to a Slack message. When a support agent posts the message, the write belongs on the live API path because Slack must accept it. Corsair stores the response as a local entity, so the ticket page can read the message without issuing another Slack request. If the message changes in Slack, a webhook can update the local row. The support ticket and its private notes remain application data; the Slack message remains provider data represented in Corsair’s projection.
This distinction also clarifies refresh behavior. A refresh button is not a command to merge an arbitrary response into UI state by hand. It is a request to run a provider operation whose result enters the same persistence path as any other API response. The page then reads the database representation. That makes the read side consistent with webhook updates and gives application tables a stable entity to reference.
The separation is especially useful when the same page combines several providers. A work queue might show GitHub issues beside Slack messages and calendar events. The page can issue local queries in parallel, sort or group the results in application code, and render them with one loading model. A user action still selects the provider-specific live operation that can change the source. The application is not forced to choose between a single universal provider API and a pile of unrelated feature-specific caches; it gets a common storage boundary while retaining the identity of each entity.
The boundary also makes failure visible at the right layer. If a local list query fails, the application is dealing with its database or projection. If a mutation fails, it may be a provider error, an expired credential, a rate limit, or a validation problem. If the mutation succeeds but the page still shows old data, the relevant question is whether the response was persisted and whether the page reread .db, not whether a frontend cache happened to be invalidated. A stable path does not remove debugging work, but it gives each symptom a smaller set of likely causes.
The rule is simple, but it is not absolute. A page that displays a provider’s current billing status immediately before a consequential decision may need .api, even if it normally reads .db. A page showing a long list of issues usually benefits from .db, with a separate refresh action or event path. The correct choice follows the user-visible requirement: does the operation change remote state, need current remote state, or mainly render state already synchronized for the application?
Five shared tables and a maintained projection
Corsair keeps different integrations on a shared persistence model instead of creating a new application table for every provider resource. The source-backed model has five tables. An integration row represents an enabled plugin. An account row connects one tenant to one integration and holds encrypted connection configuration. Entity rows hold synchronized provider resources. Event rows record API or webhook activity. Permission rows hold approval requests for gated actions.
For this chapter, the important table is corsair_entities, but its scope comes from the account relationship. An entity row includes an internal ID, the provider’s entity identifier, an entity type, a schema version, the account that owns it, and the provider data. The account ties that row to the correct tenant and plugin. This is why the local database can hold a Slack message and a GitHub issue in one common table without claiming that their payloads have the same fields or meaning: the entity type and plugin-specific schema preserve their identity inside the shared envelope.
A plugin contributes the schema for its own entities. Corsair uses that schema to create typed entity clients. A Slack message can expose fields such as text and channel according to the Slack plugin’s definition; a GitHub issue can expose its own title, state, user, and repository-related data. When an entity is read, the stored data is parsed against the plugin schema. The common database shape gives the system a place to store the row, while the plugin schema gives the row provider-specific meaning.
The local client supports ordinary operations such as listing, finding by provider entity ID, counting, deleting, and searching. Search can constrain entity metadata and supported top-level fields in the stored data. String fields can use matching operators such as equality, containment, prefixes, suffixes, or membership; numbers and dates have corresponding comparison operators. Those filters are useful because they let an application ask for, say, open issues or messages in a channel without writing provider-specific SQL for every screen.
There are boundaries to that convenience. The typed search path filters top-level data fields; it does not provide a general query language over arbitrary nested provider objects. It also has no built-in sort option in the documented client, so an application that needs SQL-level ordering or more specialized pagination may need to order results in its own code or use its ORM directly. Raw SQL is more flexible, but it removes the automatic safety of the typed account-scoped client. A raw query must restrict itself to the current tenant’s account rows.
That trade-off is visible in the way an entity changes over time. The provider identifier is the stable handle used to find a resource, while the internal row ID is the stable handle the application can reference. The entity type says which plugin schema should interpret the data, and the version records which schema definition produced the stored representation. When a webhook or API response updates the entity, the application can continue to point at the same local row while the provider data changes. A support ticket therefore does not need to chase a new row every time a message is edited.
The database abstraction supports ordinary application composition rather than isolating integration data on a separate island. An application table can keep its own ticket, note, or review record and reference the Corsair entity row. A query can then combine application-owned fields with the current provider projection. The join is useful because it keeps ownership clear: Corsair can update the provider side, while the application decides whether a ticket is open, who is reviewing it, or what private context belongs beside it.
The repository and documentation consistently enumerate five shared tables, although one passage in the database documentation refers to “six Corsair tables.” The migration examples, quick start, core database type, and table reference support five. That small inconsistency is a useful reminder about this kind of abstraction: when a count matters to a migration or an operational decision, use the current schema and source-backed definition rather than copying an isolated sentence.
The shared table has another product consequence. Application-specific state should not be forced into the provider projection. If a ticket needs a private note, a review decision, or a local tag, the application can store that value in its own table and reference the stable Corsair entity ID. The provider entity can continue to change when API calls or webhooks arrive, while the application’s relationship to that entity remains under application control. This is a stronger boundary than copying provider data into a second feature-specific table and then trying to keep both copies synchronized.
The same model supports a knowledge base. An application can search GitHub issues, comments, Slack messages, and pages from connected services, normalize the matches in its own code, and pass selected text to a model as grounding. The advantage is not that Corsair has discovered a universal meaning for every entity. The advantage is that current provider data already has a common storage location and tenant scope, so retrieval does not have to call several search APIs and reconcile several response formats for every question.
That retrieval path has an explicit limit. A contains filter is substring matching. It can find a known name, ID, or phrase, but it does not understand synonyms, correct typos, or rank conceptual relevance. For semantic search, the application can maintain embeddings in its own table keyed to Corsair entity IDs, then use Corsair to fetch the current text for a selected entity. The local projection remains useful, but it is not being mistaken for a vector index or a model of meaning.
Freshness is a policy, not a property of storage
Calling the local layer “always fresh” without qualification would hide the actual mechanism. A row is refreshed when a successful API path upserts it, when a webhook is delivered and processed, or when the application explicitly polls or refreshes the provider. If none of those things happens, the row can be old. A provider can fail, a webhook sender can omit an event, a resource can be missing from an initial backfill, and different providers can expose different deletion or update behavior.
Webhooks improve freshness without making delivery perfect. Corsair’s processing model can recognize an update for an entity it has not seen before, fetch current provider data, create the local row, and continue processing. That out-of-order recovery means the application does not have to assume that a “created” event always arrived first. It does not mean that every provider event will arrive, that events arrive exactly once, or that a provider’s payload contains enough information to rebuild every resource without a follow-up request.
Backfill and ongoing freshness solve different problems. Backfill calls list operations to populate the local projection initially. Webhooks or polling maintain it afterward. A refresh button is an application-level choice for a user who needs an explicit sync. For a freshness-critical workflow, polling may still be necessary because the webhook documentation warns that most senders do not guarantee delivery. The right design can combine all three: backfill the initial corpus, accept webhook updates, and periodically verify the resources whose absence would matter.
This is why freshness belongs in product requirements rather than in a database slogan. A dashboard for browsing recent issues may accept event-driven delay and offer a refresh button. A reconciliation job for financial or access-control state may need a direct provider read before acting. A knowledge base can use local reads for speed but should expose timestamps or source links so users understand what the answer represents. Corsair provides the paths; the application chooses the freshness policy for each feature.
The API/database split therefore turns a provider integration into an application state loop. Live calls and incoming events enter through Corsair’s common lifecycle. The result is validated and stored in a shared, tenant-scoped projection that dashboards and retrieval can query. The projection reduces repeated network work and makes provider data joinable with application records, but its accuracy still depends on the paths that update it. Those paths, in turn, need the right account and credential before they can act. The next question is how Corsair selects, protects, and renews those credentials for the correct connected account.