Crux
GuidesStorage

RecordStore

Store Crux JSON records for memory, cache, flows, evals, and workspace metadata.

RecordStore is Crux's durable JSON record interface.

Use it when Crux needs to read, write, list, filter, or watch structured state:

import { inMemoryRecordStore } from "@use-crux/core/storage";

const records = inMemoryRecordStore();

await records.put("notes:1", { title: "First note" });
const note = await records.get("notes:1");

It is not a vector index and it is not an asset store. If a feature needs similarity search, give it a VectorStore. If a workspace needs binary files, give it an AssetStore.

Common Uses

Workspace metadata uses records for paths, MIME types, sizes, timestamps, previews, and asset URIs:

workspace({
  id: "thread-files",
  namespace: threadId,
  records,
  assets,
});

Retrieval systems usually pair records with vectors:

retriever({
  id: "docs",
  records,
  vectors,
  dense,
});

TTL

put() and create() accept an optional TTL in milliseconds:

await records.put(
  "cache:brand:abc",
  { text: "Brand voice" },
  { ttlMs: 300_000 },
);

Check capability levels before writing TTL-backed data:

const capabilities = records.capabilities();

if (capabilities.ttl !== false) {
  await records.put(key, value, { ttlMs: 300_000 });
}

Bundled TTL behavior:

StoreTTL behavior
inMemoryRecordStore()Lazy expiry on get(), list(), and scan()
convexRecordStore()Lazy expiry through the Convex document contract
upstashRedisRecordStore()Native Redis PX key expiration

If capabilities().ttl === false, passing ttlMs throws StorageError code ttl_unsupported.

Filters

Record filters are exact top-level scalar equality:

const page = await records.list("docs:", {
  filter: { namespace: "public", active: true, deletedAt: null },
});

null matches explicit JSON null, not a missing field. Arrays, nested paths, regex, provider-specific filter strings, and operators are outside the beta contract. Unsupported filter values must throw StorageError code invalid_filter.

Bundled Record Stores

Use inMemoryRecordStore() for tests and examples:

import { inMemoryRecordStore } from "@use-crux/core/storage";

const records = inMemoryRecordStore();

Use convexRecordStore() or convexStorage() when your app already runs on Convex. The Convex adapter owns decoded-value filters, lazy TTL suppression, and the component page shape. See the Convex guide for setup and boundary rules.

Use Upstash Redis for a simple durable record store:

import { Redis } from "@upstash/redis";
import { upstashRedisRecordStore } from "@use-crux/upstash";

const records = upstashRedisRecordStore({
  redis: new Redis({
    url: process.env.UPSTASH_REDIS_REST_URL!,
    token: process.env.UPSTASH_REDIS_REST_TOKEN!,
  }),
});

Watches And Reactive UI

watch(prefix, callback) is optional. Implement it when your backend can push changes and you want reactive UI without polling.

records.watch?.("plan:", (event) => {
  if (event.type === "put") {
    console.log("Updated", event.key, event.value);
  } else {
    console.log("Deleted", event.key);
  }
});

Reactive UI adapters consume the same RecordStore records through a transport. Prefer prefix-scoped watches when your backend supports them and polling when it does not.

Custom RecordStore

A custom record store is straightforward if your database can read/write JSON records and list by key prefix. This is the full contract:

import type { JsonObject, RecordStore } from "@use-crux/core/storage";

const records: RecordStore = {
  async get(key) {
    return null;
  },

  async put(key, value, options) {
    // persist JsonObject
  },

  async create(key, value, options) {
    // atomically insert only when absent
    return true;
  },

  async delete(key) {
    // delete record
  },

  async list(prefix, options) {
    return { entries: [] };
  },

  capabilities() {
    return { ttl: false, filter: false, watch: false, batch: false };
  },
};

Core features use namespaced keys internally. Preserve keys exactly and make prefix listing deterministic. If a consumer needs complete traversal, implement scan() or make sure repeated list(prefix, { cursor }) calls exhaust all pages.

A database-backed implementation:

import type { JsonObject, RecordStore } from "@use-crux/core/storage";

export function myRecordStore(): RecordStore {
  return {
    async get(key) {
      const row = await db.records.findUnique({ where: { key } });
      return row ? (row.value as JsonObject) : null;
    },

    async put(key, value) {
      await db.records.upsert({
        where: { key },
        create: { key, value },
        update: { value },
      });
    },

    async create(key, value) {
      try {
        await db.records.create({ data: { key, value } });
        return true;
      } catch (error) {
        if (isUniqueConstraintError(error)) return false;
        throw error;
      }
    },

    async delete(key) {
      await db.records.deleteMany({ where: { key } });
    },

    async list(prefix = "") {
      const rows = await db.records.findMany({
        where: { key: { startsWith: prefix } },
        orderBy: { key: "asc" },
      });

      return {
        entries: rows.map((row) => ({
          key: row.key,
          value: row.value as JsonObject,
        })),
      };
    },

    capabilities() {
      return { ttl: false, filter: "native", watch: false, batch: false };
    },
  };
}

If your database also supports vectors or assets, implement VectorStore or AssetStore as separate capabilities and bundle them with storage().

For a complete SQL implementation, see the Postgres RecordStore cookbook.

On this page