Skip to content

Playground ​

The Routier playground is an editor and runtime in your browser. Every example is editable TypeScript, with autocomplete and type checking for Routier's API. Press Run (or Ctrl/⌘ + Enter) and it compiles and runs right on the page against the real plugins: memory, localStorage, IndexedDB, and SQLite and PostgreSQL compiled to WebAssembly. There's nothing to install and no account to create.

  • Edit anything. Your changes are kept per example; Reset brings back the original.
  • Write your own. Start from Sandbox, or import any plugin: @routier/sqlite-plugin, @routier/pglite-plugin, @routier/dexie-plugin, @routier/browser-storage-plugin.
  • Scripts or components. Export run(log) to log results, or export a React component to render it.
  • Share it. Share copies a link with your code in it.

Open the Playground →

Devtools are on

Every example is mounted in Routier devtools. Click the Routier button in the bottom-right corner of the Playground to see the example's collections and rows update as it runs, and open the Queries tab to see how each query executed.

Examples ​

Schemas & CRUD ​

Define a schema, then add, query, update, and remove entities. Run it →

ts
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { s } from "@routier/core/schema";
import { showInDevtools } from "../devtools";

type Log = (message: string, value?: unknown) => void;

// A schema describes the entity once: keys, defaults, and types all flow from it.
const productSchema = s
  .define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    category: s.string(),
    price: s.number(),
    inStock: s.boolean().default(true),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

class ShopStore extends DataStore {
  products = this.collection(productSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin(`playground-crud-${Date.now()}`));
  }
}

export async function run(log: Log) {
  const store = new ShopStore();
  showInDevtools(store, "Schemas & CRUD");

  // Create: identity keys and defaults are filled in for you.
  const added = await store.products.addAsync(
    { name: "Mechanical Keyboard", category: "accessories", price: 129 },
    { name: "4K Monitor", category: "displays", price: 399 },
    { name: "USB-C Hub", category: "accessories", price: 49 },
  );
  await store.saveChangesAsync();
  log("Added three products", added);

  // Read: filter and sort with typed lambdas.
  const accessories = await store.products
    .where(p => p.category === "accessories")
    .sortDescending(p => p.price)
    .toArrayAsync();
  log("Accessories, most expensive first", accessories.map(p => `${p.name} ($${p.price})`));

  // Parameterized filters keep values out of the expression.
  const affordable = await store.products.where(([p, params]) => p.price < params.max, { max: 150 }).countAsync();
  log("Products under $150", affordable);

  // Update: entities are change-tracked, so assign and save.
  const monitor = await store.products.firstAsync(p => p.name === "4K Monitor");
  monitor.price = 349;
  await store.saveChangesAsync();
  log("Monitor after price change", await store.products.firstAsync(p => p.name === "4K Monitor"));

  // Delete.
  await store.products.removeAsync(monitor);
  await store.saveChangesAsync();
  log("Products remaining", await store.products.countAsync());
}

Live queries ​

.subscribe() turns a query live. The callback receives the current result and runs again every time a saved change affects it. Run it →

ts
import { DataStore } from "@routier/datastore";
import { showInDevtools } from "../devtools";
import { MemoryPlugin } from "@routier/memory-plugin";
import { s } from "@routier/core/schema";

type Log = (message: string, value?: unknown) => void;

const taskSchema = s
  .define("tasks", {
    id: s.string().key().identity(),
    title: s.string(),
    done: s.boolean().default(false),
  })
  .compile();

class TaskStore extends DataStore {
  tasks = this.collection(taskSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin(`playground-live-${Date.now()}`));
  }
}

const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export async function run(log: Log) {
  const store = new TaskStore();
  showInDevtools(store, "Live queries");

  // subscribe() makes the query live: the callback receives the current result now,
  // and again every time a saved change affects it.
  const unsubscribe = store.tasks
    .where(t => t.done === false)
    .subscribe()
    .toArray(result => {
      if (result.ok === "error") {
        log("Query failed", result.error);
        return;
      }

      log(`Live result: ${result.data.length} open task(s)`, result.data.map(t => t.title));
    });

  await pause(700);
  log("Adding two tasks…");
  const [docs, playground] = await store.tasks.addAsync(
    { title: "Write the docs" },
    { title: "Build the playground" },
  );
  await store.saveChangesAsync();

  await pause(700);
  log("Completing “Build the playground”…");
  playground.done = true;
  await store.saveChangesAsync();

  await pause(700);
  log("Removing “Write the docs”…");
  await store.tasks.removeAsync(docs);
  await store.saveChangesAsync();

  await pause(700);
  unsubscribe();
  log("Unsubscribed. Later changes no longer reach the callback.");
}

Live data grid ​

A searchable, sortable, paged product grid. The visible page is one live query, where → sort → skip → take → subscribe, and a second subscribed count drives the pager. Turn on Simulate traffic to add, update, and remove rows in the background: the current page refreshes itself and changed rows flash. See Pagination for the pattern. Try it →

tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { InferType, s } from "@routier/core/schema";
import { useQuery, type LiveQueryState } from "@routier/react";
import { showInDevtools } from "../devtools";

const productSchema = s
  .define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    category: s.string(),
    price: s.number(),
    stock: s.number(),
    updatedAt: s.date().default(() => new Date()),
  })
  .compile();

type Product = InferType<typeof productSchema>;

class InventoryStore extends DataStore {
  products = this.collection(productSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin("playground-grid"));
  }
}

type SortKey = "name" | "category" | "price" | "stock" | "updatedAt";
type Filters = { category: string; search: string };

const SORT_SELECTORS: Record<SortKey, (p: Product) => Product[keyof Product]> = {
  name: p => p.name,
  category: p => p.category,
  price: p => p.price,
  stock: p => p.stock,
  updatedAt: p => p.updatedAt,
};

// One parameterized filter covers every combination of search box and category menu.
function matching(store: InventoryStore, filters: Filters) {
  return store.products.where(
    ([p, params]) =>
      (params.category === "All" || p.category === params.category) &&
      p.name.toLowerCase().includes(params.search),
    { category: filters.category, search: filters.search.trim().toLowerCase() },
  );
}

export function LiveGrid() {
  const store = useMemo(() => new InventoryStore(), []);
  useEffect(() => showInDevtools(store, "Live data grid"), [store]);
  const [filters, setFilters] = useState<Filters>({ category: "All", search: "" });
  const [sort, setSort] = useState<{ key: SortKey; descending: boolean }>({ key: "name", descending: false });
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(10);
  const [simulating, setSimulating] = useState(false);
  const [activity, setActivity] = useState<string[]>([]);

  useEffect(() => {
    void seed(store);
  }, [store]);

  // The visible page is a single live query: filter → sort → skip/take → subscribe.
  // Changing filters, sort, or page rebuilds it; saved data changes re-run it.
  const rows = useQuery<Product[]>(
    callback => {
      const query = matching(store, filters);
      const selector = SORT_SELECTORS[sort.key];
      const ordered = sort.descending ? query.sortDescending(selector) : query.sort(selector);

      return ordered
        .skip((page - 1) * pageSize)
        .take(pageSize)
        .subscribe()
        .toArray(callback);
    },
    [store, filters, sort, page, pageSize],
  );

  // A second live query keeps the pager's total current.
  const total = useQuery<number>(
    callback => matching(store, filters).subscribe().count(callback),
    [store, filters],
  );

  const visible = useLastSuccess(rows, [] as Product[]);
  const count = useLastSuccess(total, 0);
  const pageCount = Math.max(1, Math.ceil(count / pageSize));

  // Rows removed from under the last page: step back to a page that exists.
  useEffect(() => {
    if (page > pageCount) setPage(pageCount);
  }, [page, pageCount]);

  const { changed, refreshes } = useChangedRows(visible, JSON.stringify([filters, sort, page, pageSize]));

  const visibleRef = useRef(visible);
  visibleRef.current = visible;

  useEffect(() => {
    if (!simulating) return;

    const timer = setInterval(async () => {
      const message = await simulateTraffic(store, visibleRef.current);
      setActivity(current => [message, ...current].slice(0, 5));
    }, 900);

    return () => clearInterval(timer);
  }, [simulating, store]);

  const updateFilters = (next: Partial<Filters>) => {
    setFilters(current => ({ ...current, ...next }));
    setPage(1);
  };

  const toggleSort = (key: SortKey) => {
    setSort(current => ({ key, descending: current.key === key ? !current.descending : false }));
    setPage(1);
  };

  const addProduct = async () => {
    const [product] = await store.products.addAsync(makeProduct(1000 + Math.floor(Math.random() * 9000)));
    await store.saveChangesAsync();
    setActivity(current => [`You added ${product.name}`, ...current].slice(0, 5));
  };

  // Rows are change-tracked entities: mutate, then save.
  const adjustStock = async (product: Product, delta: number) => {
    product.stock = Math.max(0, product.stock + delta);
    product.updatedAt = new Date();
    await store.saveChangesAsync();
  };

  const remove = async (product: Product) => {
    await store.products.removeAsync(product);
    await store.saveChangesAsync();
  };

  const first = count === 0 ? 0 : (page - 1) * pageSize + 1;
  const last = Math.min(page * pageSize, count);

  return (
    <div className="grid-demo">
      <div className="grid-toolbar">
        <input
          type="search"
          aria-label="Search products"
          placeholder="Search products…"
          value={filters.search}
          onChange={e => updateFilters({ search: e.target.value })}
        />
        <select aria-label="Category" value={filters.category} onChange={e => updateFilters({ category: e.target.value })}>
          {["All", ...CATEGORIES].map(category => (
            <option key={category}>{category}</option>
          ))}
        </select>
        <button type="button" onClick={addProduct}>
          + Add product
        </button>
        <button
          type="button"
          className={simulating ? "toggle is-on" : "toggle"}
          aria-pressed={simulating}
          onClick={() => setSimulating(on => !on)}
        >
          {simulating ? "■ Stop traffic" : "▶ Simulate traffic"}
        </button>
      </div>

      <div className="grid-scroll">
        <table className="grid">
          <thead>
            <tr>
              {COLUMNS.map(column => (
                <th
                  key={column.key}
                  className={column.numeric ? "numeric" : undefined}
                  aria-sort={sort.key === column.key ? (sort.descending ? "descending" : "ascending") : "none"}
                >
                  <button type="button" onClick={() => toggleSort(column.key)}>
                    {column.label}
                    <span className="sort-indicator">{sort.key === column.key ? (sort.descending ? "▼" : "▲") : ""}</span>
                  </button>
                </th>
              ))}
              <th>
                <span className="visually-hidden">Actions</span>
              </th>
            </tr>
          </thead>
          <tbody>
            {rows.status === "error" && (
              <tr>
                <td colSpan={6} className="grid-empty">
                  Error: {rows.error.message}
                </td>
              </tr>
            )}
            {rows.status === "success" && visible.length === 0 && (
              <tr>
                <td colSpan={6} className="grid-empty">
                  No products match.
                </td>
              </tr>
            )}
            {visible.map(product => (
              <tr key={product.id} className={changed.has(product.id) ? "is-changed" : undefined}>
                <td>{product.name}</td>
                <td>
                  <span className="pill">{product.category}</span>
                </td>
                <td className="numeric">${product.price.toFixed(2)}</td>
                <td className="numeric">
                  <span className="stepper">
                    <button type="button" aria-label={`Decrease stock of ${product.name}`} onClick={() => adjustStock(product, -1)}>
                      −
                    </button>
                    <span className={product.stock < 5 ? "low-stock" : undefined}>{product.stock}</span>
                    <button type="button" aria-label={`Increase stock of ${product.name}`} onClick={() => adjustStock(product, 1)}>
                      +
                    </button>
                  </span>
                </td>
                <td className="numeric muted-cell">{new Date(product.updatedAt).toLocaleTimeString()}</td>
                <td className="numeric">
                  <button type="button" className="row-delete" aria-label={`Remove ${product.name}`} onClick={() => remove(product)}>
                    ✕
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="grid-footer">
        <span>
          {first}–{last} of {count}
          <span className="live-dot">● live · {refreshes} refreshes</span>
        </span>
        <span className="pager">
          <select
            aria-label="Rows per page"
            value={pageSize}
            onChange={e => {
              setPageSize(Number(e.target.value));
              setPage(1);
            }}
          >
            {[10, 25, 50].map(size => (
              <option key={size} value={size}>
                {size} / page
              </option>
            ))}
          </select>
          <button type="button" aria-label="First page" disabled={page === 1} onClick={() => setPage(1)}>
            «
          </button>
          <button type="button" aria-label="Previous page" disabled={page === 1} onClick={() => setPage(p => p - 1)}>
            ‹
          </button>
          <span className="page-label">
            Page {page} of {pageCount}
          </span>
          <button type="button" aria-label="Next page" disabled={page >= pageCount} onClick={() => setPage(p => p + 1)}>
            ›
          </button>
          <button type="button" aria-label="Last page" disabled={page >= pageCount} onClick={() => setPage(pageCount)}>
            »
          </button>
        </span>
      </div>

      {activity.length > 0 && (
        <ul className="activity" aria-label="Recent changes">
          {activity.map((message, index) => (
            <li key={`${activity.length - index}-${message}`}>{message}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
  { key: "name", label: "Product" },
  { key: "category", label: "Category" },
  { key: "price", label: "Price", numeric: true },
  { key: "stock", label: "Stock", numeric: true },
  { key: "updatedAt", label: "Updated", numeric: true },
];

/** Keeps showing the last delivered data while a rebuilt query (new page, sort, filter) loads. */
function useLastSuccess<T>(state: LiveQueryState<T>, initial: T): T {
  const last = useRef(initial);

  if (state.status === "success") last.current = state.data;

  return last.current;
}

/** Demo chrome: flags rows whose values changed while the same page stayed on screen. */
function useChangedRows(rows: Product[], windowKey: string) {
  const signature = (p: Product) => `${p.name}|${p.price}|${p.stock}`;
  const previous = useRef({ rows, windowKey, signatures: new Map<string, string>() });
  const [changed, setChanged] = useState<Set<string>>(new Set());
  const [refreshes, setRefreshes] = useState(0);

  useEffect(() => {
    // Same delivery as last time (for example, only the page number changed so far).
    if (rows === previous.current.rows) return;

    const before = previous.current;
    previous.current = { rows, windowKey, signatures: new Map(rows.map(p => [p.id, signature(p)])) };

    // A different window is a new page, not a change to this one.
    if (before.windowKey !== windowKey) return;

    setRefreshes(n => n + 1);

    const ids = rows.filter(p => before.signatures.get(p.id) !== signature(p)).map(p => p.id);

    if (ids.length === 0) return;

    setChanged(new Set(ids));
    const timer = setTimeout(() => setChanged(new Set()), 1000);

    return () => clearTimeout(timer);
  }, [rows, windowKey]);

  return { changed, refreshes };
}

const CATEGORIES = ["Audio", "Cameras", "Computers", "Gaming", "Phones", "Wearables"];

const PRODUCT_NAMES: Record<string, string[]> = {
  Audio: ["Headphones", "Speaker", "Earbuds", "Soundbar"],
  Cameras: ["Mirrorless", "Action Cam", "Lens", "Tripod"],
  Computers: ["Laptop", "Monitor", "Keyboard", "Dock"],
  Gaming: ["Controller", "Headset", "Console", "Racing Wheel"],
  Phones: ["Smartphone", "Charger", "Case", "Power Bank"],
  Wearables: ["Smartwatch", "Fitness Band", "Smart Ring", "AR Glasses"],
};

const MODELS = ["Air", "Pro", "Max", "Mini", "Neo", "Ultra", "Lite", "Edge"];

function makeProduct(n: number) {
  const category = CATEGORIES[n % CATEGORIES.length];
  const names = PRODUCT_NAMES[category];

  return {
    name: `${names[Math.floor(n / CATEGORIES.length) % names.length]} ${MODELS[(n * 5) % MODELS.length]} ${100 + n}`,
    category,
    price: 19.99 + ((n * 37) % 480),
    stock: (n * 13) % 60,
  };
}

async function seed(store: InventoryStore) {
  if ((await store.products.countAsync()) > 0) return;

  await store.products.addAsync(...Array.from({ length: 240 }, (_, n) => makeProduct(n)));
  await store.saveChangesAsync();
}

/** Background writes. The grid never hears about them directly, only through its live queries. */
async function simulateTraffic(store: InventoryStore, visible: Product[]): Promise<string> {
  const roll = Math.random();

  if (roll < 0.25) {
    const [product] = await store.products.addAsync(makeProduct(1000 + Math.floor(Math.random() * 9000)));
    await store.saveChangesAsync();
    return `Added ${product.name}`;
  }

  // Favor rows on the current page so the live refresh is easy to see.
  const count = await store.products.countAsync();
  const [product] =
    visible.length > 0 && Math.random() < 0.7
      ? [visible[Math.floor(Math.random() * visible.length)]]
      : await store.products.sort(p => p.id).skip(Math.floor(Math.random() * count)).take(1).toArrayAsync();

  if (product == null) return "Nothing to change";

  if (roll < 0.9) {
    product.price = Math.round(product.price * (0.8 + Math.random() * 0.4) * 100) / 100;
    product.stock = Math.floor(Math.random() * 60);
    product.updatedAt = new Date();
    await store.saveChangesAsync();
    return `Updated ${product.name}: $${product.price.toFixed(2)}, ${product.stock} in stock`;
  }

  await store.products.removeAsync(product);
  await store.saveChangesAsync();
  return `Removed ${product.name}`;
}

React + useQuery ​

A todo list whose components re-render from live queries. See the React adapter for more. Try it →

tsx
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { InferType, s } from "@routier/core/schema";
import { useQuery } from "@routier/react";
import { showInDevtools } from "../devtools";

const todoSchema = s
  .define("todos", {
    id: s.string().key().identity(),
    title: s.string(),
    done: s.boolean().default(false),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

type Todo = InferType<typeof todoSchema>;

class TodoStore extends DataStore {
  todos = this.collection(todoSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin("playground-react"));
  }
}

export function TodoApp() {
  // Memoize the store: a new DataStore on every render would resubscribe forever.
  const store = useMemo(() => new TodoStore(), []);
  useEffect(() => showInDevtools(store, "React todos"), [store]);
  const [title, setTitle] = useState("");

  // Both hooks re-render the component whenever a saved change affects their query.
  const todos = useQuery<Todo[]>(
    callback => store.todos.sort(t => t.createdAt).subscribe().toArray(callback),
    [store],
  );
  const remaining = useQuery<number>(
    callback => store.todos.where(t => t.done === false).subscribe().count(callback),
    [store],
  );

  const add = async (event: FormEvent) => {
    event.preventDefault();

    if (title.trim() === "") {
      return;
    }

    await store.todos.addAsync({ title: title.trim() });
    await store.saveChangesAsync();
    setTitle("");
  };

  const toggle = async (todo: Todo) => {
    todo.done = !todo.done;
    await store.saveChangesAsync();
  };

  const remove = async (todo: Todo) => {
    await store.todos.removeAsync(todo);
    await store.saveChangesAsync();
  };

  if (todos.status === "pending") return <p>Loading…</p>;
  if (todos.status === "error") return <p>Error: {todos.error.message}</p>;

  return (
    <div className="demo">
      <form onSubmit={add} className="demo-form">
        <input value={title} onChange={e => setTitle(e.target.value)} placeholder="What needs doing?" />
        <button type="submit">Add</button>
      </form>

      <ul className="demo-list">
        {todos.data.map(todo => (
          <li key={todo.id} className={todo.done ? "is-done" : undefined}>
            <label>
              <input type="checkbox" checked={todo.done} onChange={() => toggle(todo)} />
              <span>{todo.title}</span>
            </label>
            <button type="button" onClick={() => remove(todo)} aria-label={`Remove ${todo.title}`}>
              ✕
            </button>
          </li>
        ))}
      </ul>

      <p className="demo-footer">
        {todos.data.length === 0
          ? "No todos yet. Add one above."
          : `${remaining.status === "success" ? remaining.data : "…"} of ${todos.data.length} remaining`}
      </p>
    </div>
  );
}

IndexedDB persistence ​

The same store API on the Dexie plugin. Add a note, reload the page, and it's still there. Try it →

tsx
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { DataStore } from "@routier/datastore";
import { DexiePlugin } from "@routier/dexie-plugin";
import { InferType, s } from "@routier/core/schema";
import { useQuery } from "@routier/react";
import { showInDevtools } from "../devtools";

const noteSchema = s
  .define("notes", {
    id: s.string().key().identity(),
    text: s.string(),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

type Note = InferType<typeof noteSchema>;

class NotesStore extends DataStore {
  notes = this.collection(noteSchema).proxy().create();

  constructor() {
    // IndexedDB through Dexie, so notes survive a page reload.
    // Swap in `new MemoryPlugin("notes")` and nothing else in this file changes.
    super(new DexiePlugin("routier-playground-notes"));
  }
}

export function NotesApp() {
  const store = useMemo(() => new NotesStore(), []);
  useEffect(() => showInDevtools(store, "IndexedDB notes"), [store]);
  const [text, setText] = useState("");

  const notes = useQuery<Note[]>(
    callback => store.notes.sortDescending(n => n.createdAt).subscribe().toArray(callback),
    [store],
  );

  const add = async (event: FormEvent) => {
    event.preventDefault();

    if (text.trim() === "") {
      return;
    }

    await store.notes.addAsync({ text: text.trim() });
    await store.saveChangesAsync();
    setText("");
  };

  const remove = async (note: Note) => {
    await store.notes.removeAsync(note);
    await store.saveChangesAsync();
  };

  const clear = async () => {
    const all = await store.notes.toArrayAsync();
    await store.notes.removeAsync(...all);
    await store.saveChangesAsync();
  };

  if (notes.status === "pending") return <p>Opening IndexedDB…</p>;
  if (notes.status === "error") return <p>Error: {notes.error.message}</p>;

  return (
    <div className="demo">
      <form onSubmit={add} className="demo-form">
        <input value={text} onChange={e => setText(e.target.value)} placeholder="Write a note, then reload the page" />
        <button type="submit">Save</button>
      </form>

      <ul className="demo-list">
        {notes.data.map(note => (
          <li key={note.id}>
            <span>
              {note.text}
              <small>{new Date(note.createdAt).toLocaleString()}</small>
            </span>
            <button type="button" onClick={() => remove(note)} aria-label="Remove note">
              ✕
            </button>
          </li>
        ))}
      </ul>

      <p className="demo-footer">
        {notes.data.length} note(s) stored in IndexedDB.{" "}
        {notes.data.length > 0 && (
          <button type="button" className="link-button" onClick={clear}>
            Clear all
          </button>
        )}
      </p>
    </div>
  );
}

localStorage ​

A store kept in localStorage with the browser storage plugin. Run it, reload the page, and run it again. Try it →

ts
import { DataStore } from "@routier/datastore";
import { BrowserStoragePlugin } from "@routier/browser-storage-plugin";
import { s } from "@routier/core/schema";
import { showInDevtools } from "../devtools";

type Log = (message: string, value?: unknown) => void;

const visitSchema = s
  .define("visits", {
    id: s.string().key().identity(),
    at: s.date().default(() => new Date()),
  })
  .compile();

class VisitStore extends DataStore {
  visits = this.collection(visitSchema).proxy().create();

  constructor() {
    super(new BrowserStoragePlugin("playground-visits", window.localStorage));
  }
}

export async function run(log: Log) {
  const store = new VisitStore();
  showInDevtools(store, "localStorage");

  await store.visits.addAsync({});
  await store.saveChangesAsync();

  const visits = await store.visits.sortDescending(v => v.at).toArrayAsync();
  log(`You have run this ${visits.length} time(s). Reload the page and run it again.`);
  log("Most recent runs", visits.slice(0, 5).map(v => v.at.toLocaleString()));
}

SQLite (WASM) ​

Real SQLite compiled to WebAssembly, through the SQLite plugin. Open the devtools Queries tab to see the SQL each query ran. Try it →

ts
import { DataStore } from "@routier/datastore";
import { SqliteDbPlugin, wasmDriver } from "@routier/sqlite-plugin";
import { s } from "@routier/core/schema";
import { showInDevtools } from "../devtools";

type Log = (message: string, value?: unknown) => void;

const orderSchema = s
  .define("orders", {
    id: s.string().key().identity(),
    customer: s.string(),
    region: s.string(),
    total: s.number(),
  })
  .compile();

class OrderStore extends DataStore {
  orders = this.collection(orderSchema).proxy().create();

  constructor() {
    super(new SqliteDbPlugin(`playground-orders-${Date.now()}.db`, { driver: wasmDriver({ storage: "memory" }) }));
  }
}

const REGIONS = ["north", "south", "east", "west"];

export async function run(log: Log) {
  const store = new OrderStore();
  showInDevtools(store, "SQLite");

  await store.orders.addAsync(
    ...Array.from({ length: 200 }, (_, i) => ({
      customer: `customer-${i % 37}`,
      region: REGIONS[i % REGIONS.length],
      total: Math.round(((i * 37) % 500) + 10),
    })),
  );
  await store.saveChangesAsync();
  log("Inserted 200 orders into SQLite running in WebAssembly");

  const big = await store.orders
    .where(([o, p]) => o.total > p.min && p.regions.includes(o.region), { min: 400, regions: ["north", "east"] })
    .sortDescending(o => o.total)
    .take(5)
    .toArrayAsync();
  log("Five largest orders over $400 in the north and east", big.map(o => `${o.customer}: $${o.total}`));

  log("Sum of every order", await store.orders.sumAsync(o => o.total));
  log("Open the Routier devtools Queries tab to see the SQL each query ran.");
}

PostgreSQL (PGlite) ​

Real PostgreSQL compiled to WebAssembly, through the PGlite plugin. The first run downloads it, so give it a few seconds. Try it →

ts
import { DataStore } from "@routier/datastore";
import { PGliteDbPlugin } from "@routier/pglite-plugin";
import { s } from "@routier/core/schema";
import { showInDevtools } from "../devtools";

type Log = (message: string, value?: unknown) => void;

const bookSchema = s
  .define("books", {
    id: s.string().key().identity(),
    title: s.string(),
    author: s.string(),
    year: s.number(),
  })
  .compile();

class LibraryStore extends DataStore {
  books = this.collection(bookSchema).proxy().create();

  constructor() {
    super(new PGliteDbPlugin(`memory://playground-library-${Date.now()}`));
  }
}

export async function run(log: Log) {
  log("Starting PostgreSQL in your browser. The first run takes a few seconds.");

  const store = new LibraryStore();
  showInDevtools(store, "PGlite");

  await store.books.addAsync(
    { title: "The Left Hand of Darkness", author: "Ursula K. Le Guin", year: 1969 },
    { title: "A Wizard of Earthsea", author: "Ursula K. Le Guin", year: 1968 },
    { title: "Kindred", author: "Octavia E. Butler", year: 1979 },
    { title: "Parable of the Sower", author: "Octavia E. Butler", year: 1993 },
  );
  await store.saveChangesAsync();

  const seventies = await store.books
    .where(([b, p]) => b.year >= p.from && b.year < p.to, { from: 1970, to: 1980 })
    .toArrayAsync();
  log("Published in the 1970s", seventies.map(b => `${b.title} (${b.year})`));

  const byAuthor = await store.books.where(b => b.author.startsWith("Ursula")).sort(b => b.year).toArrayAsync();
  log("Le Guin, oldest first", byAuthor.map(b => b.title));
}

Run it locally ​

The playground is a small Vite app in examples/playground. To run it on your machine:

bash
git clone https://github.com/Agrejus/routier.git
cd routier
npm ci
npm run build            # the playground imports the built packages
cd docs
npm run playground:dev   # http://localhost:5220

Use an example in your own project ​

Each example is a single file. Install the packages it imports, then copy the file into any TypeScript project (React examples need a React app, such as one created with npm create vite@latest -- --template react-ts):

bash
npm install @routier/core @routier/datastore @routier/memory-plugin
npm install @routier/react           # React examples
npm install @routier/dexie-plugin    # IndexedDB example

Continue with Installation and the Quick Start.

Released under the MIT License.