Repository: solidjs-community/solid-primitives
Pull request #747 (2025-02-07 13:56:59)

new package: db-store
Originally, I wanted to add a database sync ability to storage, but the added amount of required serialization/deserialization was really bad for performance, so I pivoted to making a new package. This is basically an ORM implemented as a solid store featuring optimistic updates.

At the moment, there is only an adapter for supabase, but I intend to add at least mongodb soon to avoid vendor lock-in.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: ea3fbec32fdea947f784e39610a1992a7f1d779a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/solidjs-community/solid-primitives/new/db-store?filename=.changeset/large-experts-confess.md&value=---%0A%22%40solid-primitives%2Fdb-store%22%3A%20patch%0A---%0A%0Anew%20package%3A%20db-store%20(WIP)%0A)



Patch:
diff --git a/.changeset/young-lizards-sing.md b/.changeset/young-lizards-sing.md
new file mode 100644
index 00000000..fa76749b
--- /dev/null
+++ b/.changeset/young-lizards-sing.md
@@ -0,0 +1,5 @@
+---
+"@solid-primitives/db-store": major
+---
+
+new package: db-store - a store transparently bound to a database
diff --git a/packages/db-store/LICENSE b/packages/db-store/LICENSE
new file mode 100644
index 00000000..38b41d97
--- /dev/null
+++ b/packages/db-store/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/db-store/README.md b/packages/db-store/README.md
new file mode 100644
index 00000000..cdca9de1
--- /dev/null
+++ b/packages/db-store/README.md
@@ -0,0 +1,84 @@
+<p>
+  <img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=db-store" alt="Solid Primitives db-store">
+</p>
+
+# @solid-primitives/db-store
+
+[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/db-store?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/db-store)
+[![version](https://img.shields.io/npm/v/@solid-primitives/db-store?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/db-store)
+[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process)
+
+A primitive that creates a synchronized store from a database:
+
+`createDbStore` - creates a store synchronized to a database.
+`supabaseAdapter` - adapter for [supabase](https://supabase.com/) database connections to synchronize stores from.
+
+## Installation
+
+```bash
+npm install @solid-primitives/db-store
+# or
+yarn add @solid-primitives/db-store
+# or
+pnpm add @solid-primitives/db-store
+```
+
+## How to use it
+
+```ts
+const [dbStore, setDbStore] = createDbStore({
+  adapter: supabaseAdapter(client),
+  table: 'todos',
+  filter: ({ userid }) => userid === user.id,
+  onError: handleErrors,
+});
+```
+
+The store is automatically initialized and optimistically updated both ways. Due to how databases work, the store can only ever contain an array of entries.
+
+> [!WARNING]
+> Since the order of items in the database cannot be guaranteed, the same is true for the items in the store.
+
+> [!NOTE]
+> It can take some time for the database editor to show updates. They are processed a lot faster.
+
+### Setting preliminary IDs
+
+The `id` field needs to be set by the database, so even if you set it, it needs to be overwritten in any case. It is not required to set it for your fields manually; one can also treat its absence as a sign that an insertion is not yet done in the database.
+
+### Handling errors
+
+If any change could not be successfully committed to the database, the `onError` handler is called with an Error. If the caught error was an error itself, it is used directly, else what was encountered will be set as cause for an Error "unknown error". The error will also be augmented with a "data" property containing the update, an "action" property containing "insert", "update" or "delete" and a "server" flag property that is true if the error happened while sending data to the server.
+
+### Write your own adapter
+
+Your adapter must have the following properties:
+
+```tsx
+export type DbAdapterUpdate<Row extends DbRow> =
+  { old?: Partial<Row>, new?: Partial<Row> };
+
+export type DbAdapter<Row> = {
+  insertSignal: () => DbAdapterUpdate<Row> | undefined,
+  updateSignal: () => DbAdapterUpdate<Row> | undefined,
+  deleteSignal: () => DbAdapterUpdate<Row> | undefined,
+  init: () => Promise<Row[]>,
+  insert: (data: DbAdapterUpdate<Row>) => PromiseLike<any>,
+  update: (data: DbAdapterUpdate<Row>) => PromiseLike<any>,
+  delete: (data: DbAdapterUpdate<Row>) => PromiseLike<any>
+};
+```
+
+## Demo
+
+[Working demonstration](https://primitives.solidjs.community/playground/db-store/) (requires Supabase account)
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
+
+## Plans
+
+This is an early draft; in the future, more adapters are planned: mongodb, prism, firebase, aws?
+
+
diff --git a/packages/db-store/dev/index.tsx b/packages/db-store/dev/index.tsx
new file mode 100644
index 00000000..658e0238
--- /dev/null
+++ b/packages/db-store/dev/index.tsx
@@ -0,0 +1,142 @@
+import { Component, createSignal, For, Show } from "solid-js";
+import { createDbStore, supabaseAdapter, DbRow, DbStoreError } from "../src/index.js";
+import { createClient, SupabaseClient } from "@supabase/supabase-js";
+import { reconcile } from "solid-js/store";
+
+const TodoList = (props: { client: SupabaseClient }) => {
+  const [error, setError] = createSignal<DbStoreError<DbRow>>();
+  const [todos, setTodos] = createDbStore({
+    adapter: supabaseAdapter({ client: props.client, table: "todos" }),
+    onError: setError,
+  });
+  const [edit, setEdit] = createSignal<DbRow>();
+  const done = (task: DbRow) => setTodos(reconcile(todos.filter(todo => todo !== task)));
+  const add = (task: string) => setTodos(todos.length, { task });
+  return (
+    <>
+    <Show when={error()} keyed>
+      {(err) => <p class="text-red-600">{`Error: ${err.message} Cause: ${err.cause} Action: ${err.action} Data: ${JSON.stringify(err.data)}`} ${err.server ? 'server' : 'client'} <button onClick={() => setError()}>ok</button></p>}
+    </Show>
+    <ul>
+      <For
+        each={Array.from(todos).toSorted((a, b) => Number(a.id) - Number(b.id))}
+        fallback={<li>No to-dos yet</li>}
+      >
+        {item => (
+          <li class="flex flex-row items-center" data-id={item.id}>
+            <Show when={item === edit()} fallback={<span onClick={() => setEdit(item)}>{item.task}</span>}>
+              <input name="update" value={item.task} /><button onClick={() => {
+                const updated = (document.querySelector('[name="update"]') as HTMLInputElement).value;
+                const index = todos.indexOf(item);
+                if (updated && index > -1) setTodos(index, 'task', updated);
+                setEdit(undefined);
+              }}>update</button><button onClick={() => setEdit(undefined)}>cancel</button>
+            </Show>{" "}
+            <span role="button" class="p-2" onClick={() => done(item)} title="Done">
+              x
+            </span>
+          </li>
+        )}
+      </For>
+      <li class="flex flex-row items-center">
+        <label>
+          new task: <input name="task" />
+        </label>{" "}
+        <button
+          onClick={() => {
+            const task = (document.querySelector('[name="task"]') as HTMLInputElement).value;
+            if (task) {
+              add(task);
+              (document.querySelector('[name="task"]') as HTMLInputElement).value = "";
+            }
+          }}
+        >
+          do it!
+        </button>
+      </li>
+    </ul>
+    </>
+  );
+};
+
+const App: Component = () => {
+  const [client, setClient] = createSignal<SupabaseClient<any, "public", any>>();
+  const connect = () => {
+    const url = (document.querySelector('[type="url"]') as HTMLInputElement | null)?.value;
+    const key = (document.querySelector('[type="password"]') as HTMLInputElement | null)?.value;
+    url && key && setClient(createClient(url, key));
+  };
+
+  return (
+    <div class="box-border flex min-h-screen w-full flex-col items-center justify-center space-y-4 bg-gray-800 p-24 text-white">
+      <div class="wrapper-v">
+        <h4>db-store-backed to-do list</h4>
+        <Show
+          when={client()}
+          keyed
+          fallback={
+            <>
+              <details>
+                <summary>To configure your own database,</summary>
+                <ul class="list-disc">
+                  <li>
+                    Register with <a href="https://supabase.com">Supabase</a>.
+                  </li>
+                  <li>
+                    Create a new database and note down the url and the key (that usually go into an
+                    environment)
+                  </li>
+                  <li>Within the database, create a table and configure it to be public, promote changes in realtime and has no row protection:
+
+                    <pre><code>{
+`-- Create table
+create table todos (
+  id serial primary key,
+  task text
+);
+-- Turn off row-level security
+alter table "todos"
+disable row level security;
+-- Allow anonymous access
+create policy "Allow anonymous access"
+on todos
+for select
+to anon
+using (true);`
+          }</code></pre>
+                  </li>
+                  <li>Fill in the url and key in the fields below and press "connect".</li>
+                </ul>
+              </details>
+              <p>
+                <label>
+                  DB
+                  <select>
+                    <option value="supabase">SupaBase</option>
+                  </select>
+                </label>
+              </p>
+              <p>
+                <label>
+                  Client URL <input type="url" />
+                </label>
+              </p>
+              <p>
+                <label>
+                  Client Key <input type="password" />
+                </label>
+              </p>
+              <button class="btn" onClick={connect}>
+                Connect
+              </button>
+            </>
+          }
+        >
+          {(client: SupabaseClient) => <TodoList client={client} />}
+        </Show>
+      </div>
+    </div>
+  );
+};
+
+export default App;
diff --git a/packages/db-store/package.json b/packages/db-store/package.json
new file mode 100644
index 00000000..e2889af6
--- /dev/null
+++ b/packages/db-store/package.json
@@ -0,0 +1,63 @@
+{
+  "name": "@solid-primitives/db-store",
+  "version": "0.0.100",
+  "description": "A template primitive example.",
+  "author": "Your Name <you@youremail.com>",
+  "contributors": [],
+  "license": "MIT",
+  "homepage": "https://primitives.solidjs.community/package/db-store",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+  },
+  "bugs": {
+    "url": "https://github.com/solidjs-community/solid-primitives/issues"
+  },
+  "primitive": {
+    "name": "db-store",
+    "stage": 0,
+    "list": [
+      "createDbStore",
+      "supabaseAdapter"
+    ],
+    "category": "Reactivity"
+  },
+  "keywords": [
+    "solid",
+    "primitives"
+  ],
+  "private": false,
+  "sideEffects": false,
+  "files": [
+    "dist"
+  ],
+  "type": "module",
+  "module": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "browser": {},
+  "exports": {
+    "@solid-primitives/source": "./src/index.ts",
+    "import": {
+      "types": "./dist/index.d.ts",
+      "default": "./dist/index.js"
+    }
+  },
+  "typesVersions": {},
+  "scripts": {
+    "dev": "tsx ../../scripts/dev.ts",
+    "build": "tsx ../../scripts/build.ts",
+    "vitest": "vitest -c ../../configs/vitest.config.ts",
+    "test": "pnpm run vitest",
+    "test:ssr": "pnpm run vitest --mode ssr"
+  },
+  "dependencies": {
+    "@solid-primitives/resource": "workspace:^"
+  },
+  "peerDependencies": {
+    "solid-js": "^1.6.12",
+    "@supabase/supabase-js": "2.*"
+  },
+  "devDependencies": {
+    "@supabase/supabase-js": "^2.48.1"
+  }
+}
diff --git a/packages/db-store/src/index.ts b/packages/db-store/src/index.ts
new file mode 100644
index 00000000..940eeda4
--- /dev/null
+++ b/packages/db-store/src/index.ts
@@ -0,0 +1,257 @@
+import {
+  createEffect,
+  createResource,
+  createSignal,
+  createMemo,
+  on,
+  onCleanup,
+  untrack,
+  DEV,
+} from "solid-js";
+import { createStore, reconcile, SetStoreFunction, Store, unwrap } from "solid-js/store";
+import { RealtimePostgresChangesPayload, SupabaseClient } from "@supabase/supabase-js";
+
+export type DbRow = Record<string, any> & { id: number | string };
+
+export type DbAdapterUpdate<Row extends DbRow> = { old?: Partial<Row>; new?: Partial<Row> };
+
+export type DbAdapterAction = "insert" | "update" | "delete";
+
+export type DbAdapterFilter<Row extends DbRow> = (
+  ev: { action: DbAdapterAction } & DbAdapterUpdate<Row>,
+) => boolean;
+
+export type DbAdapterOptions<Row extends DbRow> = {
+  client: SupabaseClient;
+  filter?: DbAdapterFilter<Row>;
+  table: string;
+};
+
+export type DbAdapter<Row extends DbRow> = {
+  insertSignal: () => DbAdapterUpdate<Row> | undefined;
+  updateSignal: () => DbAdapterUpdate<Row> | undefined;
+  deleteSignal: () => DbAdapterUpdate<Row> | undefined;
+  init: () => Promise<{ data?: Row[]; error?: unknown }>;
+  insert: (data: DbAdapterUpdate<Row>) => PromiseLike<any>;
+  update: (data: DbAdapterUpdate<Row>) => PromiseLike<any>;
+  delete: (data: DbAdapterUpdate<Row>) => PromiseLike<any>;
+};
+
+export type DbStoreError<Row extends DbRow> = Error & {
+  data: DbAdapterUpdate<Row>;
+  action: DbAdapterAction;
+  server: boolean;
+};
+
+const createEventMemo = <Row extends DbRow>(
+  eventBase: { readonly action: "insert" | "update" | "delete" },
+  eventSignal: () => DbAdapterUpdate<Row> | undefined,
+  filter?: DbAdapterFilter<Row>,
+) =>
+  createMemo((prev: DbAdapterUpdate<Row> | undefined) => {
+    const data = eventSignal();
+    const next = data ? Object.assign(eventBase, data) : undefined;
+    if (!next || (filter && !filter(next))) return prev;
+    return next;
+  });
+
+const supabaseHandleError =
+  <Row extends DbRow>(data: DbAdapterUpdate<Row>, action: DbAdapterAction, server = false) =>
+  ({ error }: { error: unknown | null }): Promise<void> =>
+    error
+      ? Promise.reject(
+          Object.assign(
+            error instanceof Error
+              ? error
+              : new Error(typeof error === "string" ? error : `Unknown error`, { cause: error }),
+            { data, action, server },
+          ),
+        )
+      : Promise.resolve();
+
+export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>): DbAdapter<Row> => {
+  const [insertSignal, setInsertSignal] = createSignal<DbAdapterUpdate<Row>>();
+  const [updateSignal, setUpdateSignal] = createSignal<DbAdapterUpdate<Row>>();
+  const [deleteSignal, setDeleteSignal] = createSignal<DbAdapterUpdate<Row>>();
+  const updateHandler = (ev: RealtimePostgresChangesPayload<Row>) => {
+    if (ev.eventType === "INSERT") {
+      setInsertSignal(ev);
+    } else if (ev.eventType === "DELETE") {
+      setDeleteSignal(ev);
+    } else {
+      setUpdateSignal(ev);
+    }
+  };
+  const channel = opts.client
+    .channel("schema-db-changes")
+    .on("postgres_changes", { event: "*", schema: "public" }, updateHandler)
+    .subscribe();
+  onCleanup(() => channel.unsubscribe());
+  return {
+    insertSignal: createEventMemo({ action: "insert" }, insertSignal, opts.filter),
+    updateSignal: createEventMemo({ action: "update" }, updateSignal, opts.filter),
+    deleteSignal: createEventMemo({ action: "delete" }, deleteSignal, opts.filter),
+    init: () =>
+      Promise.resolve().then(() => opts.client.from(opts.table).select()) as Promise<{
+        data?: Row[];
+        error?: unknown;
+      }>,
+    insert: data =>
+      opts.client
+        .from(opts.table)
+        .insert(data.new)
+        .then(supabaseHandleError(data, "insert", true)),
+    update: data =>
+      opts.client
+        .from(opts.table)
+        .update(data.new)
+        .eq("id", data.old?.id ?? data.new?.id)
+        .then(supabaseHandleError(data, "update", true)),
+    delete: data =>
+      opts.client
+        .from(opts.table)
+        .delete()
+        .eq("id", data.old?.id ?? data.new?.id)
+        .then(supabaseHandleError(data, "delete", true)),
+  };
+};
+
+export type DbStoreOptions<Row extends DbRow> = {
+  adapter: DbAdapter<Row>;
+  init?: Row[];
+  equals?: (a: unknown, b: unknown) => boolean;
+  onError?: (err: DbStoreError<Row>) => void;
+};
+
+export const createDbStore = <Row extends DbRow>(
+  opts: DbStoreOptions<Row>,
+): [Store<Row[]>, SetStoreFunction<Row[]>, { refetch: () => void }] => {
+  const insertions = new Set<Partial<Row>>();
+  const [dbStore, setDbStore] = createStore<Row[]>(opts.init || []);
+  const [dbInit, { refetch }] = createResource(opts.adapter.init);
+  const equals = opts.equals || ((a, b) => a === b);
+  const onError = (error: DbStoreError<Row>) => {
+    if (typeof opts.onError === "function") {
+      opts.onError(error);
+    } else if (DEV) {
+      // eslint-disable-next-line no-console
+      console.error(error);
+    }
+    return Promise.resolve();
+  };
+  createEffect(() =>
+    !dbInit.loading && dbInit.error
+      ? opts.onError?.(dbInit.error)
+      : dbInit()?.data?.length
+        ? setDbStore(reconcile(dbInit()?.data!))
+        : setDbStore(reconcile([])),
+  );
+  createEffect(
+    on(opts.adapter.insertSignal, inserted => {
+      if (!inserted?.new?.id) return;
+      for (const row of insertions.values()) {
+        if (Object.entries(inserted.new).some(([key, value]) => key !== "id" && row[key] !== value))
+          continue;
+        const index = untrack(() =>
+          dbStore.findIndex(cand =>
+            Object.entries(cand).every(([key, value]) => key === "id" || row[key] == value),
+          ),
+        );
+        if (index !== -1) {
+          // @ts-ignore
+          setDbStore(index, "id", inserted.new.id);
+          insertions.delete(row);
+          return;
+        }
+      }
+      setDbStore(dbStore.length, inserted.new as Row);
+    }),
+  );
+  createEffect(
+    on(opts.adapter.updateSignal, update => {
+      const updateId = update?.new?.id ?? update?.old?.id;
+      if (updateId && Object.keys(update?.new || {}).length > 0) {
+        const previousIndex = dbStore.findIndex(({ id }) => id === updateId);
+        if (previousIndex > -1) {
+          setDbStore(previousIndex, { ...dbStore[previousIndex], ...update!.new! } as Row);
+        }
+      }
+    }),
+  );
+  createEffect(
+    on(opts.adapter.deleteSignal, deletion => {
+      const deletionId = deletion?.old?.id;
+      if (deletionId) {
+        setDbStore(reconcile(dbStore.filter(({ id }) => id !== deletionId)));
+      }
+    }),
+  );
+
+  const findRow = (rows: Row[], row: Row) =>
+    rows.find(
+      (candidate?: Row) =>
+        candidate &&
+        Object.entries(row).every(
+          ([key, value]) => !(key in candidate) || candidate[key] === value,
+        ),
+    );
+  const set = function (...args: any[]) {
+    const prev = [...unwrap(dbStore)];
+    const prevData: Map<Row, Row> = prev.reduce((map, item) => {
+      map.set(item, structuredClone(item));
+      return map;
+    }, new Map<Row, Row>());
+    const result = (setDbStore as any)(...args);
+    const next = unwrap(dbStore);
+    const deleted = prev.filter(row => !findRow(next, row));
+    const inserted = next.filter(row => !findRow(prev, row));
+    const updated: DbAdapterUpdate<Row>[] = [];
+    next.forEach(row => {
+      const prevRow = prevData.get(row);
+      if (prevRow) {
+        const updatedFields: Partial<Row> = {};
+        for (const key in row) if (!equals(prevRow[key], row[key])) updatedFields[key] = row[key];
+        for (const key in prevRow)
+          if (!(key in updatedFields) && !equals(prevRow[key], row[key]))
+            updatedFields[key] = row[key];
+        if ("id" in row) {
+          updated.push({ old: { id: row.id }, new: updatedFields } as any as DbAdapterUpdate<Row>);
+        }
+      }
+    });
+    prevData.clear();
+    Promise.allSettled([
+      ...deleted.map(row => {
+        const id = "id" in row ? row.id : undefined;
+        if (id) {
+          return Promise.resolve()
+            .then(() => opts.adapter.delete({ old: { id } as unknown as Partial<Row> }))
+            .catch(error =>
+              supabaseHandleError(
+                { old: { id } as unknown as Partial<Row> },
+                "delete",
+              )({ error }).catch(onError),
+            );
+        }
+      }),
+      ...inserted.map((item?: Partial<Row>) => {
+        if (!item) return;
+        item = { ...unwrap(item), id: undefined };
+        insertions.add(item);
+        return Promise.resolve()
+          .then(() => opts.adapter.insert({ new: item }))
+          .catch(error =>
+            supabaseHandleError({ new: item, old: {} }, "insert")({ error }).catch(onError),
+          );
+      }),
+      ...updated.map(update =>
+        Promise.resolve()
+          .then(() => opts.adapter.update(update))
+          .catch(error => supabaseHandleError(update, "update")({ error }).catch(onError)),
+      ),
+    ]);
+    return result;
+  };
+
+  return [dbStore, set, { refetch }];
+};
diff --git a/packages/db-store/tsconfig.json b/packages/db-store/tsconfig.json
new file mode 100644
index 00000000..64f690ff
--- /dev/null
+++ b/packages/db-store/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "composite": true,
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "references": [
+    {
+      "path": "../resource"
+    }
+  ],
+  "include": [
+    "src"
+  ]
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f16fe365..f69ea268 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -237,6 +237,19 @@ importers:
         specifier: ^1.8.7
         version: 1.8.20
 
+  packages/db-store:
+    dependencies:
+      '@solid-primitives/resource':
+        specifier: workspace:^
+        version: link:../resource
+      solid-js:
+        specifier: ^1.6.12
+        version: 1.8.22
+    devDependencies:
+      '@supabase/supabase-js':
+        specifier: ^2.48.1
+        version: 2.48.1
+
   packages/deep:
     dependencies:
       '@solid-primitives/memo':
@@ -2692,6 +2705,28 @@ packages:
   '@solidjs/start@1.0.6':
     resolution: {integrity: sha512-O5knaeqDBx+nKLJRm5ZJurnXZtIYBOwOreQ10APaVtVjKIKKRC5HxJ1Kwqg7atOQNNDgsF0pzhW218KseaZ1UA==}
 
+  '@supabase/auth-js@2.67.3':
+    resolution: {integrity: sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==}
+
+  '@supabase/functions-js@2.4.4':
+    resolution: {integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==}
+
+  '@supabase/node-fetch@2.6.15':
+    resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
+    engines: {node: 4.x || >=6.0.0}
+
+  '@supabase/postgrest-js@1.18.1':
+    resolution: {integrity: sha512-dWDnoC0MoDHKhaEOrsEKTadWQcBNknZVQcSgNE/Q2wXh05mhCL1ut/jthRUrSbYcqIw/CEjhaeIPp7dLarT0bg==}
+
+  '@supabase/realtime-js@2.11.2':
+    resolution: {integrity: sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==}
+
+  '@supabase/storage-js@2.7.1':
+    resolution: {integrity: sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==}
+
+  '@supabase/supabase-js@2.48.1':
+    resolution: {integrity: sha512-VMD+CYk/KxfwGbI4fqwSUVA7CLr1izXpqfFerhnYPSi6LEKD8GoR4kuO5Cc8a+N43LnfSQwLJu4kVm2e4etEmA==}
+
   '@tailwindcss/container-queries@0.1.1':
     resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
     peerDependencies:
@@ -2772,6 +2807,9 @@ packages:
   '@types/node@22.5.4':
     resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==}
 
+  '@types/phoenix@1.6.6':
+    resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
+
   '@types/resolve@1.20.2':
     resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
 
@@ -8170,6 +8208,48 @@ snapshots:
       - vinxi
       - vite
 
+  '@supabase/auth-js@2.67.3':
+    dependencies:
+      '@supabase/node-fetch': 2.6.15
+
+  '@supabase/functions-js@2.4.4':
+    dependencies:
+      '@supabase/node-fetch': 2.6.15
+
+  '@supabase/node-fetch@2.6.15':
+    dependencies:
+      whatwg-url: 5.0.0
+
+  '@supabase/postgrest-js@1.18.1':
+    dependencies:
+      '@supabase/node-fetch': 2.6.15
+
+  '@supabase/realtime-js@2.11.2':
+    dependencies:
+      '@supabase/node-fetch': 2.6.15
+      '@types/phoenix': 1.6.6
+      '@types/ws': 8.5.12
+      ws: 8.18.0
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
+  '@supabase/storage-js@2.7.1':
+    dependencies:
+      '@supabase/node-fetch': 2.6.15
+
+  '@supabase/supabase-js@2.48.1':
+    dependencies:
+      '@supabase/auth-js': 2.67.3
+      '@supabase/functions-js': 2.4.4
+      '@supabase/node-fetch': 2.6.15
+      '@supabase/postgrest-js': 1.18.1
+      '@supabase/realtime-js': 2.11.2
+      '@supabase/storage-js': 2.7.1
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
   '@tailwindcss/container-queries@0.1.1(tailwindcss@3.3.3)':
     dependencies:
       tailwindcss: 3.3.3
@@ -8265,6 +8345,8 @@ snapshots:
     dependencies:
       undici-types: 6.19.8
 
+  '@types/phoenix@1.6.6': {}
+
   '@types/resolve@1.20.2': {}
 
   '@types/sizzle@2.3.8': {}


Test patch:
diff --git a/packages/db-store/test/index.test.ts b/packages/db-store/test/index.test.ts
new file mode 100644
index 00000000..ef79f9ab
--- /dev/null
+++ b/packages/db-store/test/index.test.ts
@@ -0,0 +1,640 @@
+import { describe, it, expect, vi } from "vitest";
+import { createEffect, createRoot } from "solid-js";
+import { createDbStore, supabaseAdapter } from "../src/index.js";
+import {
+  mockSupabaseClient,
+  mockSupabaseClientData,
+  mockSupabaseResponses,
+  mockSupabaseSendEvent,
+} from "./supabase-mock.js";
+import { RealtimePostgresChangesPayload } from "@supabase/supabase-js";
+import { reconcile } from "solid-js/store";
+
+describe("supabaseAdapter", () => {
+  it("resolves the initial data", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    return Promise.resolve()
+      .then(() => adapter.init())
+      .then(data => {
+        expect(data).toEqual({ data: mockSupabaseClientData, error: null });
+        cleanup();
+      });
+  });
+
+  it("relays insertions to the insertSignal", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    const insertionEvent: RealtimePostgresChangesPayload<any> = {
+      eventType: "INSERT",
+      schema: "*",
+      old: {},
+      new: { id: 1, data: "test" },
+      table: "test",
+      commit_timestamp: new Date().toUTCString(),
+      errors: [],
+    };
+    return new Promise<void>(resolve => {
+      createRoot(() =>
+        createEffect(() => {
+          const insertion = adapter.insertSignal();
+          if (insertion?.new) {
+            expect(insertion).toHaveProperty("action", "insert");
+            expect(insertion).toHaveProperty("new", insertionEvent.new);
+            resolve();
+          }
+        }),
+      );
+      mockSupabaseSendEvent(insertionEvent);
+    }).finally(cleanup);
+  });
+
+  it("relays deletions to the deleteSignal", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    const deletionEvent: RealtimePostgresChangesPayload<any> = {
+      eventType: "DELETE",
+      schema: "*",
+      new: {},
+      old: { id: 1, data: "test" },
+      table: "test",
+      commit_timestamp: new Date().toUTCString(),
+      errors: [],
+    };
+    return new Promise<void>(resolve => {
+      createRoot(() =>
+        createEffect(() => {
+          const deletion = adapter.deleteSignal();
+          if (deletion?.old) {
+            expect(deletion).toHaveProperty("action", "delete");
+            expect(deletion).toHaveProperty("old", deletionEvent.old);
+            resolve();
+          }
+        }),
+      );
+      mockSupabaseSendEvent(deletionEvent);
+    }).finally(cleanup);
+  });
+
+  it("relays updates to the updateSignal", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    const updateEvent: RealtimePostgresChangesPayload<any> = {
+      eventType: "UPDATE",
+      schema: "*",
+      new: { data: "updated" },
+      old: { id: 1 },
+      table: "test",
+      commit_timestamp: new Date().toUTCString(),
+      errors: [],
+    };
+    return new Promise<void>(resolve => {
+      createRoot(() =>
+        createEffect(() => {
+          const update = adapter.updateSignal();
+          if (update?.old) {
+            expect(update).toHaveProperty("action", "update");
+            expect(update).toHaveProperty("old", updateEvent.old);
+            resolve();
+          }
+        }),
+      );
+      mockSupabaseSendEvent(updateEvent);
+    }).finally(cleanup);
+  });
+
+  it("relays the insertions to the database", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    return adapter.insert({ new: { data: "test" } }).then(cleanup);
+  });
+
+  it("collects errors during insertions to the database", async () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    const originalResponse = mockSupabaseResponses.insert;
+    mockSupabaseResponses.insert = Promise.resolve({ error: new Error("expected error") });
+    const data = { new: {} };
+    try {
+      await adapter.insert(data);
+      expect.fail("expected error missing");
+    } catch (e) {
+      if (e instanceof Error && e.message === "expected error missing") throw e;
+      expect(e).toBeInstanceOf(Error);
+      expect(e).toHaveProperty("action", "insert");
+      expect(e).toHaveProperty("data", data);
+    }
+    mockSupabaseResponses.insert = originalResponse;
+    cleanup();
+  });
+
+  it("relays the deletion to the database", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    return adapter.delete({ old: { id: 1 } }).then(cleanup);
+  });
+
+  it("collects errors during deletion to the database", async () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    const originalResponse = mockSupabaseResponses.delete;
+    mockSupabaseResponses.delete = Promise.resolve({ error: new Error("expected error") });
+    const data = { old: {} };
+    try {
+      await adapter.delete(data);
+      expect.fail("expected error missing");
+    } catch (e) {
+      if (e instanceof Error && e.message === "expected error missing") throw e;
+      expect(e).toBeInstanceOf(Error);
+      expect(e).toHaveProperty("action", "delete");
+      expect(e).toHaveProperty("data", data);
+    }
+    mockSupabaseResponses.delete = originalResponse;
+    cleanup();
+  });
+
+  it("relays the update to the database", () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    return adapter.update({ old: { id: 1 }, new: { data: "tested" } }).then(cleanup);
+  });
+
+  it("collects errors during update to the database", async () => {
+    const [adapter, cleanup] = createRoot(dispose => [
+      supabaseAdapter({
+        client: mockSupabaseClient,
+        table: "test",
+      }),
+      dispose,
+    ]);
+    const originalResponse = mockSupabaseResponses.update;
+    mockSupabaseResponses.update = Promise.resolve({ error: new Error("expected error") });
+    const data = { old: { id: 1 }, new: { test: "tested" } };
+    try {
+      await adapter.update(data);
+      expect.fail("expected error missing");
+    } catch (e) {
+      if (e instanceof Error && e.message === "expected error missing") throw e;
+      expect(e).toBeInstanceOf(Error);
+      expect(e).toHaveProperty("action", "update");
+      expect(e).toHaveProperty("data", data);
+    }
+    mockSupabaseResponses.update = originalResponse;
+    cleanup();
+  });
+});
+
+describe("createDbStore", () => {
+  it("initializes the store", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            createEffect((counter: number = 0) => {
+              if (counter === 0) {
+                expect(dbStore).toEqual([]);
+              } else {
+                expect(dbStore).toEqual(mockSupabaseClientData);
+                dispose();
+                resolve();
+              }
+              return counter + 1;
+            });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("refetches from the database on calling refetch", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore, _, { refetch }] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            createEffect((counter: number = 0) => {
+              if (counter < 1) {
+                expect(dbStore).toEqual([]);
+              } else if (counter == 1) {
+                expect(dbStore).toEqual(mockSupabaseClientData);
+                mockSupabaseClientData.push({ id: 3, data: "added" });
+                expect(dbStore).not.toEqual(mockSupabaseClientData);
+                refetch();
+              } else {
+                expect(dbStore).toEqual(mockSupabaseClientData);
+                dispose();
+                resolve();
+              }
+              return counter + 1;
+            });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("inserts into the database from the store", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore, setDbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            // @ts-ignore
+            vi.mocked(mockSupabaseClient.insert).mockImplementationOnce((data, _options) => {
+              expect(data).toEqual({ data: "inserted" });
+              dispose();
+              resolve();
+              return mockSupabaseResponses.insert;
+            });
+            setDbStore(dbStore.length, { data: "inserted" });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("adds ids into the store from the database", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore, setDbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            createEffect((counter: number = 0) => {
+              dbStore.length;
+              if (counter === 0) {
+                expect(dbStore).toEqual([]);
+              } else if (counter === 1) {
+                expect(dbStore).toEqual(mockSupabaseClientData);
+                setDbStore(dbStore.length, { data: "inserted" });
+              } else if (counter === 2) {
+                expect(dbStore.at(-1)).toEqual({ id: undefined, data: "inserted" });
+                mockSupabaseSendEvent({
+                  eventType: "INSERT",
+                  old: {},
+                  new: { id: 5, data: "inserted" },
+                  schema: "*",
+                  commit_timestamp: new Date().toUTCString(),
+                  table: "test",
+                  errors: [],
+                });
+              } else if (counter === 3) {
+                expect(dbStore.at(-1)).toEqual({ id: 5, data: "inserted" });
+                dispose();
+                resolve();
+              }
+              return counter + 1;
+            });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("inserts into the store from the database", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            createEffect((counter: number = 0) => {
+              if (counter === 0) {
+                expect(dbStore).toHaveLength(0);
+              } else if (counter === 1) {
+                expect(dbStore.length).toBeGreaterThan(0);
+                mockSupabaseSendEvent({
+                  eventType: "INSERT",
+                  commit_timestamp: new Date().toUTCString(),
+                  new: { id: 4, data: "inserted" },
+                  old: {},
+                  schema: "*",
+                  table: "test",
+                  errors: [],
+                });
+              } else if (counter === 2) {
+                expect(dbStore.at(-1)).toEqual({ id: 4, data: "inserted" });
+                dispose();
+                resolve();
+              }
+              return counter + 1;
+            });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("handles errors during insertion into the database", () => {
+    const originalResponse = mockSupabaseResponses.insert;
+    const errorCause = { message: "server connection lost" };
+    mockSupabaseResponses.insert = Promise.resolve({ error: errorCause });
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore, setDbStore] = createDbStore<{ id: number; data: string }>({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+              onError: error => {
+                try {
+                  expect(error).toBeInstanceOf(Error);
+                  expect(error.toString()).toBe("Error: Unknown error");
+                  expect(error.cause).toEqual(errorCause);
+                  expect(error.data).toEqual({ new: { id: undefined, data: "inserted" }, old: {} });
+                  expect(error.action).toEqual("insert");
+                  expect(error.server).toBe(false);
+                } catch (e) {
+                  dispose();
+                  reject(e);
+                }
+                dispose();
+                resolve();
+              },
+            });
+            setDbStore(dbStore.length, { data: "inserted" });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    ).finally(() => {
+      mockSupabaseResponses.insert = originalResponse;
+    });
+  });
+
+  it.sequential("updates the database from the store", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [_, setDbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+              init: [{ id: 1, data: "not yet updated" }],
+            });
+            // @ts-ignore
+            vi.mocked(mockSupabaseClient.eq).mockImplementationOnce((key, value) => {
+              expect(key).toBe("id");
+              expect(value).toBe(1);
+              dispose();
+              resolve();
+              return mockSupabaseResponses.update;
+            });
+            // @ts-ignore
+            vi.mocked(mockSupabaseClient.update).mockImplementationOnce(data => {
+              expect(data).toEqual({ data: "updated" });
+              return mockSupabaseClient;
+            });
+            setDbStore(0, { data: "updated" });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("handles errors during updates of the database", () => {
+    const originalResponse = mockSupabaseResponses.update;
+    const errorCause = { message: "server connection lost" };
+    mockSupabaseResponses.update = Promise.resolve({ error: errorCause });
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [_, setDbStore] = createDbStore<{ id: number; data: string }>({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+              init: mockSupabaseClientData,
+              onError: error => {
+                try {
+                  expect(error).toBeInstanceOf(Error);
+                  expect(error.toString()).toBe("Error: Unknown error");
+                  expect(error.cause).toEqual(errorCause);
+                  expect(error.data).toEqual({ new: { data: "updated" }, old: { id: 1 } });
+                  expect(error.action).toEqual("update");
+                  expect(error.server).toBe(false);
+                } catch (e) {
+                  dispose();
+                  reject(e);
+                }
+                dispose();
+                resolve();
+              },
+            });
+            setDbStore(0, { data: "updated" });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    ).finally(() => {
+      mockSupabaseResponses.update = originalResponse;
+    });
+  });
+
+  it.skip("updates the store from the database", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            createEffect((counter: number = 0) => {
+              // subscribe the effect to the data so all updates can be captured.
+              JSON.stringify(dbStore);
+              if (counter === 0) {
+                expect(dbStore).toHaveLength(0);
+              } else if (counter === 1) {
+                expect(dbStore.length).toBeGreaterThan(0);
+                mockSupabaseSendEvent({
+                  eventType: "UPDATE",
+                  commit_timestamp: new Date().toUTCString(),
+                  new: { data: "updated" },
+                  old: { id: 1 },
+                  schema: "*",
+                  table: "test",
+                  errors: [],
+                });
+              } else if (counter === 2) {
+                expect(dbStore[0]).toEqual({ id: 1, data: "updated" });
+                dispose();
+                resolve();
+              }
+              return counter + 1;
+            });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+
+  it.sequential("deletes from the database from the store", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore, setDbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+              init: [{ id: 1, data: "not yet deleted" }],
+            });
+            // @ts-ignore
+            vi.mocked(mockSupabaseClient.eq).mockImplementationOnce((key, value) => {
+              expect(key).toBe("id");
+              expect(value).toBe(1);
+              dispose();
+              resolve();
+              return mockSupabaseResponses.delete;
+            });
+            // @ts-ignore
+            setDbStore(dbStore.length - 1, undefined);
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+  
+  it.skip("deletes from the store from the database", () => {
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore] = createDbStore({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+            });
+            createEffect((counter: number = 0) => {
+              // subscribe the effect to the data so all updates can be captured.
+              JSON.stringify(dbStore);
+              if (counter === 0) {
+                expect(dbStore).toHaveLength(0);
+              } else if (counter === 1) {
+                expect(dbStore.length).toBeGreaterThan(0);
+                mockSupabaseSendEvent({
+                  eventType: "DELETE",
+                  commit_timestamp: new Date().toUTCString(),
+                  new: {},
+                  old: { id: 1 },
+                  schema: "*",
+                  table: "test",
+                  errors: [],
+                });
+              } else if (counter === 2) {
+                expect(dbStore[0]).toEqual({ id: 2, data: "two" });
+                dispose();
+                resolve();
+              }
+              return counter + 1;
+            });
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    );
+  });
+  
+  it.skip("handles error during deletion", () => {
+    const originalResponse = mockSupabaseResponses.delete;
+    const errorCause = { message: "server connection lost" };
+    mockSupabaseResponses.delete = Promise.resolve({ error: errorCause });
+    return createRoot(
+      dispose =>
+        new Promise<void>((resolve, reject) => {
+          try {
+            const [dbStore, setDbStore] = createDbStore<{ id: number; data: string }>({
+              adapter: supabaseAdapter({ client: mockSupabaseClient, table: "test" }),
+              init: mockSupabaseClientData,
+              onError: error => {
+                try {
+                  expect(error).toBeInstanceOf(Error);
+                  expect(error.toString()).toBe("Error: Unknown error");
+                  expect(error.cause).toEqual(errorCause);
+                  expect(error.data).toEqual({ old: { id: 1 } });
+                  expect(error.action).toEqual("delete");
+                  expect(error.server).toBe(false);
+                } catch (e) {
+                  dispose();
+                  reject(e);
+                }
+                dispose();
+                resolve();
+              },
+            });
+            setDbStore(reconcile(dbStore.filter(({ id }) => id !== 1)));
+          } catch (e) {
+            dispose();
+            reject(e);
+          }
+        }),
+    ).finally(() => {
+      mockSupabaseResponses.delete = originalResponse;
+    });
+  });
+});
diff --git a/packages/db-store/test/supabase-mock.ts b/packages/db-store/test/supabase-mock.ts
new file mode 100644
index 00000000..a850c8b4
--- /dev/null
+++ b/packages/db-store/test/supabase-mock.ts
@@ -0,0 +1,36 @@
+import { RealtimePostgresChangesPayload, SupabaseClient } from '@supabase/supabase-js';
+import { vi } from 'vitest';
+
+const mockSupabaseSubscribers: ((ev: RealtimePostgresChangesPayload<any>) => void)[] = []
+
+export const mockSupabaseClientEvents = {
+  insert: [] as Record<string, any>[],
+  update: [] as Record<string, any>[],
+  delete: [] as Record<string, any>[]
+};
+
+export const mockSupabaseClientData = [{id: 1, data: 'one'}, {id: 2, data: 'two'}];
+
+let eqResponse: 'delete' | 'update' = 'update';
+
+export const mockSupabaseClient = {
+  from: function() { return mockSupabaseClient; },
+  select: function() { return Promise.resolve({ error: null, data: mockSupabaseClientData }); },
+  channel: function() { return mockSupabaseClient; },
+  on: function(_: any, __: any, handler: (ev: RealtimePostgresChangesPayload<any>) => void) { mockSupabaseSubscribers.push(handler); return mockSupabaseClient; },
+  subscribe: function() { return mockSupabaseClient; },
+  unsubscribe: function() { mockSupabaseSubscribers.length = 0; return mockSupabaseClient; },
+  insert: vi.fn(function(_row: Record<string, any>) { return mockSupabaseResponses.insert; }),
+  delete: function(_row: { id: string | number }) { eqResponse = 'delete'; return mockSupabaseClient; },
+  update: vi.fn(function(_row: Record<string, any>) { eqResponse = 'update'; return mockSupabaseClient; }),
+  eq: vi.fn(function(_row: { id: string | number }) { return mockSupabaseResponses[eqResponse]; }),
+} as unknown as SupabaseClient;
+
+export const mockSupabaseSendEvent = (ev: RealtimePostgresChangesPayload<any>) =>
+  mockSupabaseSubscribers.forEach(s => s(ev));
+
+export const mockSupabaseResponses = {
+  insert: Promise.resolve({}),
+  delete: Promise.resolve({}),
+  update: Promise.resolve({}),
+};