Offline-first sync library using Yjs CRDTs and Convex for real-time data synchronization.
Replicate provides a dual-storage architecture for building offline-capable applications with automatic conflict resolution. It combines Yjs CRDTs with TanStack DB's reactive state management and Convex's reactive backend for real-time synchronization and efficient querying.
sequenceDiagram
participant UI as React Component
participant Collection as TanStack DB Collection
participant Yjs as Yjs CRDT
participant Storage as Local Storage<br/>(SQLite)
participant Convex as Convex Backend
participant Table as Main Table
Note over UI,Storage: Client-side (offline-capable)
UI->>Collection: insert/update/delete
Collection->>Yjs: Apply change to Y.Doc
Yjs->>Storage: Persist locally
Collection-->>UI: Re-render (optimistic)
Note over Collection,Convex: Sync layer
Collection->>Convex: Send CRDT delta
Convex->>Convex: Append to event log
Convex->>Table: Update materialized doc
Note over Convex,UI: Real-time updates
Table-->>Collection: stream subscription
Collection-->>UI: Re-render with server state
graph TB
subgraph Client
TDB[TanStack DB]
Yjs[Yjs CRDT]
Local[(SQLite)]
TDB <--> Yjs
Yjs <--> Local
end
subgraph Convex
Component[(Event Log<br/>CRDT Deltas)]
Main[(Main Table<br/>Materialized Docs)]
Component --> Main
end
Yjs -->|insert/update/remove| Component
Main -->|stream subscription| TDB
Why dual storage?
- Event Log (Component): Append-only CRDT deltas for conflict resolution and history
- Main Table: Materialized current state for efficient queries and indexes
- Similar to CQRS: event log = write model, main table = read model
# Using bun (recommended)
bun add @trestleinc/replicate
# Using pnpm
pnpm add @trestleinc/replicate
# Using npm (v7+)
npm install @trestleinc/replicateAdd the replicate component to your Convex app configuration:
// convex/convex.config.ts
import { defineApp } from 'convex/server';
import replicate from '@trestleinc/replicate/convex.config';
const app = defineApp();
app.use(replicate);
export default app;Use the schema.table() helper to automatically inject required fields:
// convex/schema.ts
import { defineSchema } from 'convex/server';
import { v } from 'convex/values';
import { schema } from '@trestleinc/replicate/server';
export default defineSchema({
tasks: schema.table(
{
// Your application fields only!
// timestamp is automatically injected by schema.table()
id: v.string(),
text: v.string(),
isCompleted: v.boolean(),
},
(t) => t
.index('by_doc_id', ['id']) // Required for document lookups
.index('by_timestamp', ['timestamp']) // Required for incremental sync
),
});What schema.table() does:
- Automatically injects
timestamp: v.number()(for incremental sync) - You only define your business logic fields
Required indexes:
by_doc_idon['id']- Enables fast document lookups during updatesby_timestampon['timestamp']- Enables efficient incremental synchronization
Use replicate() to bind your component and create collection functions:
// convex/tasks.ts
import { replicate } from '@trestleinc/replicate/server';
import { components } from './_generated/api';
import type { Task } from '../src/useTasks';
const r = replicate(components.replicate);
export const {
stream,
material,
recovery,
insert,
update,
remove,
mark, // Peer sync progress tracking
compact, // Manual compaction trigger
} = r<Task>({
collection: 'tasks',
compaction: {
sizeThreshold: "5mb", // Type-safe size: "100kb", "5mb", "1gb"
peerTimeout: "24h", // Type-safe duration: "30m", "24h", "7d"
},
});What replicate() generates:
stream- Real-time CRDT stream query (cursor-based subscriptions withseqnumbers)material- SSR-friendly query (for server-side rendering)recovery- State vector sync query (for startup reconciliation)insert- Dual-storage insert mutation (auto-compacts when threshold exceeded)update- Dual-storage update mutation (auto-compacts when threshold exceeded)remove- Dual-storage delete mutation (auto-compacts when threshold exceeded)mark- Report sync progress to server (peer tracking for safe compaction)compact- Manual compaction trigger (peer-aware, respects active peer sync state)
Create a collection definition using collection.create(). This is SSR-safe because persistence and config are deferred until init() is called in the browser:
// src/collections/tasks.ts
import { collection, persistence } from '@trestleinc/replicate/client';
import { ConvexClient } from 'convex/browser';
import { api } from '../../convex/_generated/api';
import initSqlJs from 'sql.js';
import { z } from 'zod';
// Define your Zod schema (required)
const taskSchema = z.object({
id: z.string(),
text: z.string(),
isCompleted: z.boolean(),
});
export type Task = z.infer<typeof taskSchema>;
// Create lazy-initialized collection (SSR-safe)
export const tasks = collection.create({
// Async factory - only called in browser during init()
persistence: async () => {
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
return persistence.sqlite.browser(SQL, 'tasks');
},
// Sync factory - only called in browser during init()
config: () => ({
schema: taskSchema,
convexClient: new ConvexClient(import.meta.env.VITE_CONVEX_URL),
api: api.tasks,
getKey: (task) => task.id,
}),
});Key points:
collection.create()returns a lazy collection that's safe to import during SSRpersistenceandconfigare factory functions, not values - they're only called duringinit()schemais required (Zod schema for type inference and prose field detection)- Collection name is auto-extracted from
api.tasksfunction path
Initialize the collection once in your app's entry point (browser only), then use it in components:
// src/routes/__root.tsx (or app entry point)
import { tasks } from '../collections/tasks';
// Initialize once during app startup (browser only)
// For SSR frameworks, do this in a client-side effect or loader
await tasks.init();// src/components/TaskList.tsx
import { useLiveQuery } from '@tanstack/react-db';
import { tasks, type Task } from '../collections/tasks';
export function TaskList() {
const collection = tasks.get();
const { data: taskList, isLoading, isError } = useLiveQuery(collection);
const handleCreate = () => {
collection.insert({
id: crypto.randomUUID(),
text: 'New task',
isCompleted: false,
});
};
const handleUpdate = (id: string, isCompleted: boolean) => {
collection.update(id, (draft: Task) => {
draft.isCompleted = !isCompleted;
});
};
const handleDelete = (id: string) => {
// Hard delete - physically removes from main table
collection.delete(id);
};
if (isError) {
return <div>Error loading tasks. Please refresh.</div>;
}
if (isLoading) {
return <div>Loading tasks...</div>;
}
return (
<div>
<button onClick={handleCreate}>Add Task</button>
{taskList.map((task) => (
<div key={task.id}>
<input
type="checkbox"
checked={task.isCompleted}
onChange={() => handleUpdate(task.id, task.isCompleted)}
/>
<span>{task.text}</span>
<button onClick={() => handleDelete(task.id)}>Delete</button>
</div>
))}
</div>
);
}Lifecycle:
collection.create()- Define collection (module-level, SSR-safe)await tasks.init()- Initialize persistence and config (browser only, call once)tasks.get()- Get the TanStack DB collection instance (after init)
For frameworks that support SSR (TanStack Start, Next.js, Remix, SvelteKit), preloading data on the server enables instant page loads.
Why SSR is recommended:
- Instant page loads - No loading spinners on first render
- Better SEO - Content visible to search engines
- Reduced client work - Data already available on hydration
- Seamless transition - Real-time sync takes over after hydration
Step 1: Prefetch material on the server
Use ConvexHttpClient to fetch data during SSR. The material query is generated by replicate():
// TanStack Start: src/routes/__root.tsx
import { createRootRoute } from '@tanstack/react-router';
import { ConvexHttpClient } from 'convex/browser';
import { api } from '../convex/_generated/api';
const httpClient = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
export const Route = createRootRoute({
loader: async () => {
const tasksMaterial = await httpClient.query(api.tasks.material);
return { tasksMaterial };
},
});// SvelteKit: src/routes/+layout.server.ts
import { ConvexHttpClient } from 'convex/browser';
import { api } from '../convex/_generated/api';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
const httpClient = new ConvexHttpClient(PUBLIC_CONVEX_URL);
export async function load() {
const tasksMaterial = await httpClient.query(api.tasks.material);
return { tasksMaterial };
}Step 2: Pass material to init() on the client
// TanStack Start: src/routes/__root.tsx (client component)
import { tasks } from '../collections/tasks';
function RootComponent() {
const { tasksMaterial } = Route.useLoaderData();
useEffect(() => {
// Initialize with SSR data - no loading state!
tasks.init(tasksMaterial);
}, []);
return <Outlet />;
}<!-- SvelteKit: src/routes/+layout.svelte -->
<script lang="ts">
import { tasks } from '../collections/tasks';
import { onMount } from 'svelte';
export let data; // From +layout.server.ts
onMount(async () => {
await tasks.init(data.tasksMaterial);
});
</script>Note: If your framework doesn't support SSR, just call await tasks.init() without arguments - it will fetch data on mount and show a loading state.
Replicate uses cursor-based sync with peer tracking for safe compaction.
The primary sync mechanism uses monotonically increasing sequence numbers (seq):
- Client subscribes with last known
cursor(seq number) - Server returns all changes with
seq > cursor - Client applies changes and updates local cursor
- Client calls
markto report sync progress to server - Subscription stays open for live updates
This approach enables:
- Safe compaction: Server knows which deltas each peer has synced
- Peer tracking: Active peers are tracked via
markcalls - No data loss: Compaction only removes deltas all active peers have received
Clients report their sync progress to the server:
// Called automatically after applying changes
await convexClient.mutation(api.tasks.mark, {
peerId: "client-uuid",
syncedSeq: 42, // Last processed seq number
});The server tracks:
- Which peers are actively syncing
- Each peer's last synced
seqnumber - Peer timeout for cleanup (configurable via
peerTimeout)
Compaction is safe because it respects peer sync state:
- Server checks minimum
syncedSeqacross all active peers - Only deletes deltas where
seq < minSyncedSeq - Ensures no active peer loses data they haven't synced
Compaction triggers:
- Automatic: When document deltas exceed
sizeThreshold - Manual: Via
compactmutation
Used on startup to reconcile client and server state using Yjs state vectors:
- Client encodes its local Y.Doc state vector (compact representation of what it has)
- Server merges all snapshots + deltas into full state
- Server computes diff between its state and client's state vector
- Server returns only the missing bytes
- Client applies the diff to catch up
When recovery is used:
- App startup (before stream subscription begins)
- After extended offline periods
- When cursor-based sync can't satisfy the request (deltas compacted)
Replicate uses hard deletes where items are physically removed from the main table, while the internal component preserves complete event history.
Why hard delete?
- Clean main table (no filtering required)
- Standard TanStack DB operations
- Complete audit trail preserved in component event log
- Proper CRDT conflict resolution maintained
- Foundation for future recovery features
Implementation:
// Delete handler (uses collection.delete)
const handleDelete = (id: string) => {
collection.delete(id); // Hard delete - physically removes from main table
};
// UI usage - no filtering needed!
const { data: tasks } = useLiveQuery(collection);
// SSR loader - no filtering needed!
export const Route = createFileRoute('/')({
loader: async () => {
const tasks = await httpClient.query(api.tasks.material);
return { tasks };
},
});How it works:
- Client calls
collection.delete(id) onRemovehandler captures Yjs deletion delta- Delta appended to component event log (history preserved)
- Main table: document physically removed
- Other clients notified and item removed locally
You can customize the behavior of generated functions using optional hooks:
// convex/tasks.ts
import { replicate } from '@trestleinc/replicate/server';
import { components } from './_generated/api';
import type { Task } from '../src/useTasks';
const r = replicate(components.replicate);
export const {
stream,
material,
recovery,
insert,
update,
remove,
mark,
compact,
} = r<Task>({
collection: 'tasks',
// Optional hooks for authorization and lifecycle events
hooks: {
// Permission checks (eval* hooks validate BEFORE execution, throw to deny)
evalRead: async (ctx, collection) => {
const userId = await ctx.auth.getUserIdentity();
if (!userId) throw new Error('Unauthorized');
},
evalWrite: async (ctx, doc) => {
const userId = await ctx.auth.getUserIdentity();
if (!userId) throw new Error('Unauthorized');
},
evalRemove: async (ctx, documentId) => {
const userId = await ctx.auth.getUserIdentity();
if (!userId) throw new Error('Unauthorized');
},
evalMark: async (ctx, peerId) => {
// Validate peer identity
const userId = await ctx.auth.getUserIdentity();
if (!userId) throw new Error('Unauthorized');
},
evalCompact: async (ctx, documentId) => {
// Restrict compaction to admin users
const userId = await ctx.auth.getUserIdentity();
if (!userId) throw new Error('Unauthorized');
},
// Lifecycle callbacks (on* hooks run AFTER execution)
onStream: async (ctx, result) => { /* after stream query */ },
onInsert: async (ctx, doc) => { /* after insert */ },
onUpdate: async (ctx, doc) => { /* after update */ },
onRemove: async (ctx, documentId) => { /* after remove */ },
// Transform hook (modify documents before returning)
transform: async (docs) => docs.filter(d => d.isPublic),
}
});For collaborative rich text editing, use the schema.prose() validator and prose.extract() function:
// convex/schema.ts
import { schema } from '@trestleinc/replicate/server';
export default defineSchema({
notebooks: schema.table({
id: v.string(),
title: v.string(),
content: schema.prose(), // ProseMirror-compatible JSON
}),
});
// Client: Extract plain text for search
import { prose } from '@trestleinc/replicate/client';
const plainText = prose.extract(notebook.content);
// Client: Get editor binding for ProseMirror/TipTap
const binding = await collection.utils.prose(notebookId, 'content');Choose the right storage backend for your platform. Persistence is configured in the persistence factory of collection.create():
import { collection, persistence } from '@trestleinc/replicate/client';
// Browser SQLite: Uses sql.js WASM with OPFS persistence
export const tasks = collection.create({
persistence: async () => {
const initSqlJs = (await import('sql.js')).default;
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
return persistence.sqlite.browser(SQL, 'my-app-db');
},
config: () => ({ /* ... */ }),
});
// React Native SQLite: Uses op-sqlite (native SQLite)
export const tasks = collection.create({
persistence: async () => {
const { open } = await import('@op-engineering/op-sqlite');
const db = open({ name: 'my-app-db' });
return persistence.sqlite.native(db, 'my-app-db');
},
config: () => ({ /* ... */ }),
});
// Testing: In-memory (no persistence)
export const tasks = collection.create({
persistence: async () => persistence.memory(),
config: () => ({ /* ... */ }),
});
// Custom backend: Implement StorageAdapter interface
export const tasks = collection.create({
persistence: async () => persistence.custom(new MyCustomAdapter()),
config: () => ({ /* ... */ }),
});SQLite Browser - Uses sql.js (SQLite compiled to WASM) with OPFS persistence. You initialize sql.js yourself and pass the SQL object.
SQLite Native - Uses op-sqlite for React Native. You create the database and pass it.
Memory - No persistence, useful for testing.
Custom - Implement StorageAdapter for any storage backend.
Implement StorageAdapter for custom storage (Chrome extensions, localStorage, cloud storage):
import { persistence, type StorageAdapter } from '@trestleinc/replicate/client';
class ChromeStorageAdapter implements StorageAdapter {
async get(key: string): Promise<Uint8Array | undefined> {
const result = await chrome.storage.local.get(key);
return result[key] ? new Uint8Array(result[key]) : undefined;
}
async set(key: string, value: Uint8Array): Promise<void> {
await chrome.storage.local.set({ [key]: Array.from(value) });
}
async delete(key: string): Promise<void> {
await chrome.storage.local.remove(key);
}
async keys(prefix: string): Promise<string[]> {
const all = await chrome.storage.local.get(null);
return Object.keys(all).filter(k => k.startsWith(prefix));
}
}
// Use custom adapter
const chromePersistence = persistence.custom(new ChromeStorageAdapter());Configure logging for debugging and development using LogTape:
// src/routes/__root.tsx or app entry point
import { configure, getConsoleSink } from '@logtape/logtape';
await configure({
sinks: { console: getConsoleSink() },
loggers: [
{
category: ['convex-replicate'],
lowestLevel: 'debug', // 'debug' | 'info' | 'warn' | 'error'
sinks: ['console']
}
],
});Creates a lazy-initialized collection with deferred persistence and config resolution. Both persistence and config are factory functions that are only called when init() is invoked (browser-only).
Parameters:
persistence- Async factory function that returns aPersistenceinstanceconfig- Sync factory function that returns the collection config (ConvexClient, schema, api, etc.)
Returns: LazyCollection with init(material?) and get() methods
Example:
import { collection, persistence } from '@trestleinc/replicate/client';
import { ConvexClient } from 'convex/browser';
import initSqlJs from 'sql.js';
export const tasks = collection.create({
persistence: async () => {
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
return persistence.sqlite.browser(SQL, 'tasks');
},
config: () => ({
schema: taskSchema,
convexClient: new ConvexClient(import.meta.env.VITE_CONVEX_URL),
api: api.tasks,
getKey: (task) => task.id,
}),
});
// In your app initialization (browser only):
// Pass SSR-prefetched material for instant hydration
await tasks.init(material);
const collection = tasks.get();SSR Prefetch (server-side):
// SvelteKit: +layout.server.ts
import { ConvexHttpClient } from 'convex/browser';
const httpClient = new ConvexHttpClient(PUBLIC_CONVEX_URL);
export async function load() {
const material = await httpClient.query(api.tasks.material);
return { material };
}The config factory in collection.create() accepts these options:
interface CollectionConfig<T> {
schema: ZodObject; // Required: Zod schema for type inference
getKey: (item: T) => string | number; // Extract unique key from item
convexClient: ConvexClient; // Convex client instance
api: { // API from replicate()
stream: FunctionReference; // Real-time subscription
insert: FunctionReference; // Insert mutation
update: FunctionReference; // Update mutation
remove: FunctionReference; // Delete mutation
recovery: FunctionReference; // State vector sync
mark: FunctionReference; // Peer sync tracking
compact: FunctionReference; // Manual compaction
material?: FunctionReference; // SSR hydration query
};
undoCaptureTimeout?: number; // Undo stack merge window (default: 500ms)
}Example:
export const tasks = collection.create({
persistence: async () => {
const SQL = await initSqlJs({ locateFile: (f) => `/${f}` });
return persistence.sqlite.browser(SQL, 'tasks');
},
config: () => ({
schema: taskSchema,
getKey: (task) => task.id,
convexClient: new ConvexClient(import.meta.env.VITE_CONVEX_URL),
api: api.tasks,
}),
});Extract plain text from ProseMirror JSON.
Parameters:
proseJson- ProseMirror JSON structure (XmlFragmentJSON)
Returns: string - Plain text content
Example:
import { prose } from '@trestleinc/replicate/client';
const plainText = prose.extract(task.content);import { persistence, type StorageAdapter } from '@trestleinc/replicate/client';
// Persistence providers (use in collection.create persistence factory)
persistence.sqlite.browser(SQL, name) // Browser: sql.js WASM + OPFS
persistence.sqlite.native(db, name) // React Native: op-sqlite
persistence.memory() // Testing: in-memory (no persistence)
persistence.custom(adapter) // Custom: your StorageAdapter implementationpersistence.sqlite.browser(SQL, name) - Browser SQLite using sql.js WASM. You initialize sql.js and pass the SQL object.
persistence.sqlite.native(db, name) - React Native SQLite using op-sqlite. You create the database and pass it.
persistence.memory() - In-memory, no persistence. Useful for testing.
persistence.custom(adapter) - Custom storage backend. Pass your StorageAdapter implementation.
Implement for custom storage backends:
interface StorageAdapter {
/** Get value by key, returns undefined if not found */
get(key: string): Promise<Uint8Array | undefined>;
/** Set value by key */
set(key: string, value: Uint8Array): Promise<void>;
/** Delete value by key */
delete(key: string): Promise<void>;
/** List all keys matching prefix */
keys(prefix: string): Promise<string[]>;
/** Optional: cleanup when persistence is destroyed */
close?(): void;
}import { errors } from '@trestleinc/replicate/client';
errors.Network // Network-related failures
errors.IDB // Storage read errors
errors.IDBWrite // Storage write errors
errors.Reconciliation // Phantom document cleanup errors
errors.Prose // Rich text field errors
errors.CollectionNotReady// Collection not initialized
errors.NonRetriable // Errors that should not be retried (auth, validation)Factory function that creates a bound replicate function for your collections.
Parameters:
component- Your Convex component reference (components.replicate)
Returns: A function <T>(config: ReplicateConfig<T>) that generates collection operations.
Example:
import { replicate } from '@trestleinc/replicate/server';
import { components } from './_generated/api';
const r = replicate(components.replicate);
export const tasks = r<Task>({ collection: 'tasks' });Configuration for the bound replicate function.
Config:
interface ReplicateConfig<T> {
collection: string; // Collection name (e.g., 'tasks')
// Optional: Compaction settings with type-safe values
compaction?: {
sizeThreshold?: Size; // Size threshold: "100kb", "5mb", "1gb" (default: "5mb")
peerTimeout?: Duration; // Peer timeout: "30m", "24h", "7d" (default: "24h")
};
// Optional: Hooks for permissions and lifecycle
hooks?: {
// Permission checks (throw to reject)
evalRead?: (ctx, collection) => Promise<void>;
evalWrite?: (ctx, doc) => Promise<void>;
evalRemove?: (ctx, documentId) => Promise<void>;
evalMark?: (ctx, peerId) => Promise<void>;
evalCompact?: (ctx, documentId) => Promise<void>;
// Lifecycle callbacks (run after operation)
onStream?: (ctx, result) => Promise<void>;
onInsert?: (ctx, doc) => Promise<void>;
onUpdate?: (ctx, doc) => Promise<void>;
onRemove?: (ctx, documentId) => Promise<void>;
// Transform hook (modify documents before returning)
transform?: (docs) => Promise<T[]>;
};
}Type-safe values:
Size:"100kb","5mb","1gb", etc.Duration:"30m","24h","7d", etc.
Returns: Object with generated functions:
stream- Real-time CRDT stream query (cursor-based withseqnumbers)material- SSR-friendly query for hydrationrecovery- State vector sync query (for startup reconciliation)insert- Dual-storage insert mutation (auto-compacts when threshold exceeded)update- Dual-storage update mutation (auto-compacts when threshold exceeded)remove- Dual-storage delete mutation (auto-compacts when threshold exceeded)mark- Peer sync tracking mutation (reportssyncedSeqto server)compact- Manual compaction mutation (peer-aware, safe for active clients)
Automatically inject timestamp field for incremental sync.
Parameters:
userFields- User's business logic fieldsapplyIndexes- Optional callback to add indexes
Returns: TableDefinition with replication fields injected
Example:
import { schema } from '@trestleinc/replicate/server';
tasks: schema.table(
{
id: v.string(),
text: v.string(),
},
(t) => t
.index('by_doc_id', ['id'])
.index('by_timestamp', ['timestamp'])
)Validator for ProseMirror-compatible JSON fields.
Returns: Convex validator for prose fields
Example:
content: schema.prose() // Validates ProseMirror JSON structureimport type { ProseValue } from '@trestleinc/replicate/shared';
// ProseValue - branded type for prose fields in Zod schemas
// Use the prose() helper from client to create fields of this typeReact Native doesn't include the Web Crypto API by default. Install these polyfills:
npm install react-native-get-random-values react-native-random-uuidImport them at the very top of your app's entry point (before any other imports):
// index.js or app/_layout.tsx - MUST be first!
import "react-native-get-random-values";
import "react-native-random-uuid";
// Then your other imports...This provides:
crypto.getRandomValues()- Required by Yjs for CRDT operationscrypto.randomUUID()- Used for generating document and peer IDs
See examples/expo/ for a complete React Native example using Expo.
A full-featured offline-first issue tracker built with Replicate, demonstrating real-world usage patterns.
Live Demo: interval.robelest.com
Source Code: Available in three framework variants:
examples/tanstack-start/- TanStack Start (React, web)examples/sveltekit/- SvelteKit (Svelte, web)examples/expo/- Expo (React Native, mobile)
Web features demonstrated:
- Offline-first with SQLite persistence (sql.js + OPFS)
- Rich text editing with TipTap + Yjs collaboration
- PWA with custom service worker
- Real-time sync across devices
- Search with client-side text extraction (
prose.extract())
Mobile features demonstrated (Expo):
- Native SQLite persistence (op-sqlite)
- Plain TextInput prose binding via
useProseFieldhook - Crypto polyfills for React Native
bun run build # Build with tsdown (includes ESLint + TypeScript checking)
bun run dev # Watch mode
bun run clean # Remove build artifactsApache-2.0 License - see LICENSE file for details.
Copyright 2025 Trestle Inc