npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tinybase-supabase

v0.4.1

Published

Browser-only offline-first TinyBase persistence and Supabase synchronization.

Readme

tinybase-supabase

tinybase-supabase is a browser-only, offline-first persister for regular TinyBase Store instances. It stores data and an outbox in IndexedDB, writes mapped rows through the Supabase JS client, and reconciles remote state after reconnecting.

It supports both ESM and CommonJS consumers, direct Supabase CRUD, RLS, soft deletes, optional Realtime wake-and-reconcile subscriptions, and local-only or read-only TinyBase tables.

The same createSupabasePersister factory optionally supports collaborative text, map, array, and xml-fragment cells. When configured, it stores append-only Yjs updates in a child table while keeping IDs, ownership, relationships, and other RLS fields on the ordinary parent row. Configurations with no CRDT cells use only the ordinary whole-row implementation and require no updates table. See the collaborative CRDT guide for the required schema and many-to-one and many-to-many policy examples.

Documentation

The versioned source for the documentation and interactive Todo example lives in docs/. GitHub Actions builds it on every pull request and publishes the main branch to GitHub Pages.

Installation

npm install tinybase tinybase-supabase @supabase/supabase-js
import {createStore} from 'tinybase';
import {createClient} from '@supabase/supabase-js';
import {createSupabasePersister} from 'tinybase-supabase';

const store = createStore();
const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY);
const persister = await createSupabasePersister(store, {
	crdtUpdateBufferMs: 500,
	databaseName: 'my-app',
	scopeKey: 'current-user-id',
	supabase,
	tables: {
		projects: {table: 'projects', updatedAtColumn: 'updated_at'},
		todos: {
			dependsOn: ['projects'],
			realtime: true,
			table: 'todos',
			updatedAtColumn: 'updated_at',
		},
		public_templates: {
			mode: 'read-only',
			table: 'public_templates',
			updatedAtColumn: 'updated_at',
		},
	},
});

await persister.startAutoPersisting();

CommonJS projects can use the same public API:

const {createSupabasePersister} = require('tinybase-supabase');

Configuration reference

createSupabasePersister(store, config) accepts the following top-level options:

| Option | Type | Default | Description | | --- | --- | --- | --- | | supabase | Supabase client | Required | A browser client created with a publishable or anon key. Do not use a service-role client. | | databaseName | string | Required | Stable IndexedDB database name for this application. | | scopeKey | string | Required | Stable user, tenant, and authorization-version identifier. It isolates one visible row set from another in IndexedDB. | | tables | Record<string, SupabaseTableConfig> | Required | Mapped tables, keyed by TinyBase table ID. TinyBase tables omitted from this object remain local-only. | | pollIntervalMs | number | 60000 | Safety-pull interval in milliseconds. Set to 0 to disable interval pulls. | | pageSize | number | 500 | Maximum number of parent rows or CRDT update rows fetched per Supabase page. | | cursorLookbackMs | number | 300000 | Overlap before configured parent-row cursors. It recovers transactions that commit shortly out of timestamp order. | | crdtUpdateBufferMs | number | 500 | Time to buffer and merge local Yjs updates per row before upload. Set to 0 for immediate upload; local durability is not delayed. | | retryBaseDelayMs | number | 1000 | Initial delay for exponential retry after a transient synchronization failure. | | retryMaxDelayMs | number | 30000 | Maximum delay between retries after repeated transient failures. | | onError | (error: Error) => void | None | Receives IndexedDB lifecycle, persistence, Realtime, and synchronization errors. |

Each entry in tables supports these options:

| Option | Type | Default | Description | | --- | --- | --- | --- | | table | string | Required | Remote Supabase table name. The containing object key is the TinyBase table ID. | | mode | 'read-write' \| 'read-only' | 'read-write' | Read-only mappings pull remote rows but never enqueue or upload local ordinary or CRDT writes. | | idColumn | string | 'id' | Existing remote primary-key column. IDs must be created by the application and are always added to outgoing rows. | | deletedAtColumn | string | 'deleted_at' | Existing nullable timestamp column used for soft-delete tombstones. | | updatedAtColumn | string | None | Existing server-managed timestamp used as the incremental pull cursor. Strongly recommended; when omitted, every reconciliation performs a full authoritative pull. | | cursorVersion | string | '' | Stable remote-mapping version. Change it after a select/codec migration to force a new full pull. | | dependsOn | readonly string[] | [] | TinyBase table IDs that must be uploaded before this table. Tombstones are sent in the reverse dependency order. | | select | string | '*' | Columns passed to Supabase select() during parent-row reconciliation. Include the configured ID and soft-delete columns, plus the updated-at column when incremental pulls are enabled. | | toRemote | (rowId: string, row: Row) => SupabaseRow | Identity mapping | Encodes a TinyBase row for Supabase. The configured ID column is added after this function returns. | | fromRemote | (row: SupabaseRow) => [string, Row] | Default column mapping | Decodes a remote row. By default, the ID becomes the TinyBase row ID; the ID, soft-delete, and configured updated-at columns are omitted from its cells. | | realtime | boolean \| RealtimeTableConfig | false | Enables Postgres Changes as a debounced pull wake-up. Use true for the defaults or an object for the options below. | | crdtCells | Record<string, CrdtCellConfig> | {} | Collaborative cells keyed by TinyBase cell ID. Each value is {type: 'text'}, {type: 'map'}, {type: 'array'}, or {type: 'xml-fragment'}. | | crdtUpdatesTable | string | None | Append-only Yjs updates table. Required when crdtCells is non-empty. | | crdtRowIdColumn | string | 'row_id' | Foreign-key column in crdtUpdatesTable that refers to the parent row. |

realtime: true uses the defaults below. Pass an object to override either option:

| Option | Type | Default | Description | | --- | --- | --- | --- | | channelName | string | 'tinybase-supabase:<scopeKey>:<tableId>' | Supabase Realtime channel name for parent-row changes. CRDT update channels use their own per-row names. | | debounceMs | number | 200 | Delay used to coalesce Realtime notifications before pulling. It applies to both parent-row and CRDT update notifications. |

For example, a configuration using every option can look like this:

const persister = await createSupabasePersister(store, {
	crdtUpdateBufferMs: 250,
	cursorLookbackMs: 300_000,
	databaseName: 'my-app',
	onError: (error) => console.error(error),
	pageSize: 250,
	pollIntervalMs: 30_000,
	retryBaseDelayMs: 500,
	retryMaxDelayMs: 15_000,
	scopeKey: user.id,
	supabase,
	tables: {
			documents: {
			crdtCells: {
				body: {type: 'text'},
				metadata: {type: 'map'},
				structuredBody: {type: 'xml-fragment'},
				tags: {type: 'array'},
			},
			crdtRowIdColumn: 'document_id',
			crdtUpdatesTable: 'document_yjs_updates',
			deletedAtColumn: 'deleted_at',
			cursorVersion: 'v1',
			updatedAtColumn: 'updated_at',
			dependsOn: ['projects'],
			fromRemote: (row) => [String(row.id), {title: String(row.title)}],
			idColumn: 'id',
			mode: 'read-write',
			realtime: {
				channelName: `documents:${user.id}`,
				debounceMs: 100,
			},
			select: 'id,title,deleted_at,updated_at',
			table: 'documents',
			toRemote: (_rowId, row) => ({title: row.title}),
		},
		projects: {table: 'projects', updatedAtColumn: 'updated_at'},
		publicTemplates: {
			mode: 'read-only',
			table: 'public_templates',
			updatedAtColumn: 'updated_at',
		},
	},
});

crdtCells can be combined with mode: 'read-only'. The persister still hydrates, follows, and projects remote Yjs updates, but local mutations through the Yjs handles throw before changing the document and are never retained or uploaded. If any collaborative cells are configured, provide the update table and policies described in the collaborative CRDT guide.

Database contract

Every mapping needs an application-created string or UUID primary key (default column: id) and a nullable soft-delete timestamp (default: deleted_at). We strongly recommend adding a non-null server-managed update timestamp, indexing it with the ID, and setting updatedAtColumn: 'updated_at'. This enables incremental pulls. Without that option, every reconciliation remains a paginated full authoritative pull for backward compatibility. Keep tombstones selectable for every scope that previously read the live row. The client never uses a service-role key. Enable RLS and grant only the operations your authenticated users need.

Each TinyBase row maps to a whole remote row. Use toRemote and fromRemote for renamed columns or domain codecs. JSON values, arrays, nullable values, dates encoded as strings, and storage-path references are supported by normal Supabase column mappings.

mode: 'read-only' pulls a table but never queues or uploads ordinary or CRDT writes. This is suitable for public reference data and data available through read-only RLS policies. For mixed-access tables, use read-write synchronization, control editing in the application, and enforce row permissions with Supabase RLS.

Synchronization behavior

Remote pulls preserve newer local edits across all tables. Successful synchronization waits for remote application and its IndexedDB commit; responses acknowledge or reject only the operation revision actually sent. See the local persistence and completion contract.

Writes are locally durable once the IndexedDB transaction completes. While offline, edits remain optimistic in TinyBase and are queued. Permanent Supabase errors such as RLS rejection are retained as rejected operations; inspect them with getRejectedOperations, then retry or discard them. Ordinary rejected operations retain their optimistic TinyBase row when discarded. A rejected CRDT update quarantines that document's outbound history: later local edits stay optimistic but cannot upload ahead of the rejected update. Retrying uploads the merged causal chain; discarding removes every unaccepted local CRDT update, closes the affected row, and requires openRow before further use.

During a CRDT retry, the document remains quarantined until the complete merged chain is accepted. A transient retry failure leaves that chain pending for the normal backoff scheduler instead of allowing newer updates to bypass it.

Without updatedAtColumn, every pull is an automatically paginated, authoritative snapshot. When updatedAtColumn is configured, the first pull is authoritative and later pulls use the greatest server-returned timestamp with a five-minute overlap by default, so only recent and newer rows are transferred. Incremental mode requires durable soft-delete tombstones and stable RLS visibility for a scopeKey; include an authorization version in the scope whenever grants or revocations change its visible row set.

Transient failures retry with capped exponential backoff. Sync also runs on browser reconnect and when a hidden tab becomes visible, in addition to the optional periodic safety pull.

Ordinary cells use server-arrival, full-row last-write-wins. They do not promise cross-table transactions or exactly-once delivery. Cells explicitly configured as Yjs types use append-only CRDT updates and merge concurrent changes within their values.

Local Yjs updates are durable immediately, then buffered for 500 ms by default and merged per row before upload. Set crdtUpdateBufferMs to tune the collaboration-latency/row-count tradeoff, or call syncNow() to flush the current buffer immediately.

IndexedDB upgrades

An IndexedDB schema upgrade can be blocked while an older application tab keeps the same scoped database open. In that case, onError promptly receives an IndexedDbUpgradeBlockedError with code: 'indexeddb-upgrade-blocked'. The createSupabasePersister promise remains pending and completes automatically after the older connection closes. Tell users to close other tabs; initialization will resume automatically. The library does not delete or reset local content, queued writes, or rejected writes.

Connections opened by this version listen for future schema upgrades and close cooperatively. The affected consumer receives an IndexedDbConnectionClosedForUpgradeError with code: 'indexeddb-connection-closed-for-upgrade'. The persister enters the terminal error phase, stops automatic persistence and background sync, and rejects later persistence and sync operations with that same error. Consumers should reload before using the persister again. onError remains an optional notification mechanism; status listeners work without it:

persister.addSyncStatusListener((status) => {
  const error = status.lastError as Error & { code?: string };
  if (
    status.phase === 'error' &&
    error.code === 'indexeddb-connection-closed-for-upgrade'
  ) {
    showReloadRequiredScreen();
  }
});

Both error classes are exported from tinybase-supabase and expose databaseName, currentVersion, and requestedVersion fields. Prefer error.code as the stable discriminator rather than instanceof or the error message.

With realtime: true, Postgres Changes only wakes a debounced authenticated pull. CRUD remains the write path. Startup, reconnect, manual, and safety pulls use the configured durable cursor, or the full-pull fallback, rather than trusting notification delivery. Add opted-in tables to the supabase_realtime publication in your own schema.

Local development and tests

pnpm install
supabase start
supabase status -o env

Set SUPABASE_TEST_URL from API_URL and SUPABASE_TEST_ANON_KEY from ANON_KEY, then run:

pnpm check
pnpm test:unit
pnpm test:integration
pnpm test:e2e
pnpm build

The local fixture covers private authenticated todo data, invalid RLS writes, anonymous public data with and without RLS, Realtime and non-Realtime tables, and Chromium browser scenarios for offline reload, reconnect, Realtime, two-client server-arrival conflict resolution, durable CRDT rehydration, and concurrent Y.Text convergence. It also covers read-only CRDT hydration, Realtime following, blocked local uploads, authoritative restart behavior, and real two-page IndexedDB upgrade blocking and cooperative connection closure.

Formatting is enforced by Biome with four-width tabs, semicolons, single quotes, trailing commas, and no internal barrel files.