Adventure Labs Logo
Scout Docs

Extending the Schema

Typing tables and RPCs a deployment adds on top of Scout's

Extending the schema

Scout ships a Database type generated from its own migrations. A deployment that adds tables, views, functions, enums or composite types describes those additions with ExtendDatabase rather than replacing Database, so one client stays correctly typed for both schemas.

import type { ExtendDatabase } from "@adventurelabs/scout-core/types";

export type AppDatabase = ExtendDatabase<{
  Tables: {
    field_notes: {
      Row: { id: number; herd_id: number; body: string; inserted_at: string };
    };
  };
  Functions: {
    field_notes_for_herd: {
      Args: { herd_id_caller: number };
      Returns: { id: number; body: string }[];
    };
  };
  Enums: { note_kind: "observation" | "incident" };
}>;

Only Row is required for a table. Insert defaults to Row, Update to Partial<Row>, and Relationships to []. Supply them explicitly when the real column defaults differ.

Declare row shapes as type aliases rather than interfaces. PostgREST resolves a schema through an index signature, and TypeScript only infers one for aliases, so an interface Row fails the ExtendDatabase constraint.

ExtendDatabase only adds. Reusing a name Scout already defines intersects with Scout's definition instead of replacing it, which usually yields a type nothing satisfies, so pick names that do not collide.

Building a client

The client factories take the extended schema as a type argument and return a ScoutDatabaseClient<AppDatabase>.

import { createScoutBrowserClient } from "@adventurelabs/scout-core/client";
import { newServerClient } from "@adventurelabs/scout-core/server/supabase";

const browser = createScoutBrowserClient<AppDatabase>();
const server = await newServerClient<AppDatabase>();

createClientWithApiKey accepts the same type argument.

An extended schema is still assignable to Scout's, so the client works with Scout's own query helpers while also typing the added tables and functions:

await get_devices_by_herd_query(browser, herd_id); // Scout's helper
await browser.from("field_notes").select("*"); // the deployment's table
await browser.rpc("field_notes_for_herd", { herd_id_caller: herd_id });

Unknown relation and function names are still rejected, and argument types are still checked.

Deriving types

Scout's own rows already have named types (IEvent, IDevice, and so on) in @adventurelabs/scout-core/types. For an added table, name the Row type where it is declared and reuse it:

export type FieldNote = {
  id: number;
  herd_id: number;
  body: string;
  inserted_at: string;
};

export type AppDatabase = ExtendDatabase<{
  Tables: { field_notes: { Row: FieldNote } };
}>;

Generating types locally instead

A deployment that runs supabase gen types against its own project already gets one Database covering both schemas, so it describes nothing twice. Wrap it in AdoptDatabase.

import type { AdoptDatabase } from "@adventurelabs/scout-core/types";
import type { Database } from "./supabase";

export type AppDatabase = AdoptDatabase<Database>;

AdoptDatabase re-pins Scout's generator metadata. A generated file records the PostgREST version of the project it ran against, and SupabaseClient carries that version in its own type, so a deployment on a different version would otherwise build a client that Scout's helpers reject even though the two schemas agree. Schemas built with ExtendDatabase inherit Scout's metadata already and need no wrapper.

Either way the schema must still cover Scout's own tables and functions, since that is what lets one client serve both. A schema missing one is rejected where the client is built.

That generated file also carries its own Tables<>, TablesInsert<>, Enums<> and CompositeTypes<> helpers, already bound to the deployment's schema, so it needs no accessors from Scout to read members off either half.

What is not extensible

ScoutState, IHerdModule and the IndexedDB cache hold a fixed set of Scout entities. Added tables are queried directly or held in the deployment's own Redux slices alongside configureScoutStore(); useScoutRefresh does not load them.

On this page