> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bijection.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Synced Tables

> Bind tables to integration collections and read synced data

A synced table is a table in your schema that is bound to an integration
collection. The collection's sync is its only writer: your queries read it like
any other table, and your mutations can't change it.

```ts bijection/schema.ts {6-8} theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { defineSchema, defineTable } from "bijection/server";
import { v } from "bijection/values";
import { crm } from "./crm";

export default defineSchema({
  customers: defineTable(crm.customers.schema)
    .source(crm.customers)
    .index("by_external_id", ["external_id"]),
  notes: defineTable({
    customer: v.id("customers"),
    body: v.string(),
  }),
});
```

Here `customers` is filled by the `crm` integration's
[`customers` collection](/integrations/defining-integrations#collections), and
`notes` is an ordinary table your app writes, pointing at synced customers by
their document ID.

## Binding a table

Call `.source(...)` on a table definition with a collection handle, the
property of the same name on your integration:

```ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
customers: defineTable(crm.customers.schema).source(crm.customers),
```

The table's validator must be exactly the collection's `schema`. Passing
`crm.customers.schema` to `defineTable` is the simplest way to keep the two
identical; a table whose fields differ is refused with
`Source schema must match the table validator`.

You can add [indexes](/database/reading-data/indexes/indexes), search indexes
and vector indexes to a synced table as usual. A synced table can't also be a
link table, have a governing rule or use a staged validator, and a table binds
exactly one collection.

The binding holds as soon as you deploy the schema. Until a
[connection](/integrations/connections) is installed and its first sync
completes, the table is simply empty.

## Documents in a synced table

Each provider record becomes one document. The document has the fields of the
collection's schema, the usual [system fields](/database/types#system-fields)
`_id` and `_creationTime`, and one more field that Bijection sets:

* `source_id`: an opaque string identifying the installed source that
  published the record.

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "_id": "k97f2dn0a4qz5t0qg1m0h3w2b57ak6yj",
  "_creationTime": 1727510400000.123,
  "source_id": "…",
  "external_id": "C-1042",
  "name": "Northwind Supplies"
}
```

A record keeps the same `_id` when the provider updates it, so your own tables
can refer to it with `v.id("customers")`. When the provider reports that a
record was deleted, its document is removed.

`source_id` is part of the generated `Doc<"customers">` type and can be
indexed. The collection's schema can't declare a field of that name.

## Reading synced data

Queries read synced tables with the ordinary [`ctx.db`](/database/reading-data/reading-data)
API, and subscriptions update when a sync publishes new data:

```ts bijection/customers.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { query } from "./_generated/server";
import { v } from "bijection/values";

export const byExternalId = query({
  args: { externalId: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("customers")
      .withIndex("by_external_id", (q) => q.eq("external_id", args.externalId))
      .unique();
  },
});
```

Everything a sync publishes for one page appears in one transaction, so a query
never sees half of a page.

## Synced tables are read-only

The generated types leave synced tables out of the write methods, so this
doesn't type-check:

```ts bijection/customers.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
export const rename = mutation({
  args: { id: v.id("customers"), name: v.string() },
  handler: async (ctx, args) => {
    // Type error: "customers" is a synced table.
    await ctx.db.patch("customers", args.id, { name: args.name });
  },
});
```

A write that reaches the database anyway fails with a `ReadOnlySource` error:
`customers accepts changes only through its admitted source`.

To keep your own data about a synced record, store it in an ordinary table that
references the record, like `notes` above. To change the record at the
provider, send an integration command; see
[Operations](/operations/overview).

## Coverage and freshness

An empty result from a synced table can mean two different things: the
provider has no matching records, or the source has never finished a sync. To
tell them apart, read the source's coverage with `ctx.sources.coverage`, in a
query or a mutation:

```ts bijection/customers.ts {8} theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { query } from "./_generated/server";
import { v } from "bijection/values";

export const freshness = query({
  args: { sourceId: v.string() },
  handler: async (ctx, args) => {
    const coverage = await ctx.sources.coverage(args.sourceId, {
      table: "customers",
    });
    return { synced: coverage.acquired, ageMs: coverage.age };
  },
});
```

Pass the `source_id` of the rows you read. The optional `table` option also
checks that the source publishes into that table. The result has these fields:

* `acquired`: whether an initial sync has completed.
* `observedFrom` and `observedTo`: the window the current data covers, in
  milliseconds since the epoch, or `null` before the first completed sync.
* `age`: how old the newest observation is, in milliseconds.
* `checkedAt`: when the source last answered a check successfully.
* `continuityLost`: whether the source lost continuity. The data it has is still
  valid but can no longer be extended.
* `refreshOutstanding`: whether a requested refresh hasn't completed yet.

Coverage is read under the same access rules as the source's rows, and it is
tracked like any other read, so a query that depends on it re-runs when the
coverage changes.

## Several sources in one table

A synced table can receive records from more than one installed source, for
example one per connected account. Each record carries its own `source_id`.

Installing a second source into a table is refused with
`SourceReadAccessRequired` until the table declares a read access rule, because
readers of one source's records aren't automatically allowed to read
another's. See [Access rules](/access/overview).
