From 4c9dad57468dd6852b64a187f2ddf9b4150606d6 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Thu, 6 Feb 2025 15:33:05 +0100 Subject: [PATCH] new package: db-store --- .changeset/young-lizards-sing.md | 5 + packages/db-store/LICENSE | 21 + packages/db-store/README.md | 84 ++++ packages/db-store/dev/index.tsx | 142 ++++++ packages/db-store/package.json | 63 +++ packages/db-store/src/index.ts | 257 ++++++++++ packages/db-store/test/index.test.ts | 640 ++++++++++++++++++++++++ packages/db-store/test/supabase-mock.ts | 36 ++ packages/db-store/tsconfig.json | 16 + pnpm-lock.yaml | 82 +++ 10 files changed, 1346 insertions(+) create mode 100644 .changeset/young-lizards-sing.md create mode 100644 packages/db-store/LICENSE create mode 100644 packages/db-store/README.md create mode 100644 packages/db-store/dev/index.tsx create mode 100644 packages/db-store/package.json create mode 100644 packages/db-store/src/index.ts create mode 100644 packages/db-store/test/index.test.ts create mode 100644 packages/db-store/test/supabase-mock.ts create mode 100644 packages/db-store/tsconfig.json diff --git a/.changeset/young-lizards-sing.md b/.changeset/young-lizards-sing.md new file mode 100644 index 000000000..fa76749b1 --- /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 000000000..38b41d975 --- /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 000000000..cdca9de19 --- /dev/null +++ b/packages/db-store/README.md @@ -0,0 +1,84 @@ +

+ Solid Primitives db-store +

+ +# @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 = + { old?: Partial, new?: Partial }; + +export type DbAdapter = { + insertSignal: () => DbAdapterUpdate | undefined, + updateSignal: () => DbAdapterUpdate | undefined, + deleteSignal: () => DbAdapterUpdate | undefined, + init: () => Promise, + insert: (data: DbAdapterUpdate) => PromiseLike, + update: (data: DbAdapterUpdate) => PromiseLike, + delete: (data: DbAdapterUpdate) => PromiseLike +}; +``` + +## 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 000000000..658e0238a --- /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>(); + const [todos, setTodos] = createDbStore({ + adapter: supabaseAdapter({ client: props.client, table: "todos" }), + onError: setError, + }); + const [edit, setEdit] = createSignal(); + const done = (task: DbRow) => setTodos(reconcile(todos.filter(todo => todo !== task))); + const add = (task: string) => setTodos(todos.length, { task }); + return ( + <> + + {(err) =>

{`Error: ${err.message} Cause: ${err.cause} Action: ${err.action} Data: ${JSON.stringify(err.data)}`} ${err.server ? 'server' : 'client'}

} +
+
    + Number(a.id) - Number(b.id))} + fallback={
  • No to-dos yet
  • } + > + {item => ( +
  • + setEdit(item)}>{item.task}}> + + {" "} + done(item)} title="Done"> + x + +
  • + )} +
    +
  • + {" "} + +
  • +
+ + ); +}; + +const App: Component = () => { + const [client, setClient] = createSignal>(); + 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 ( +
+
+

db-store-backed to-do list

+ +
+ To configure your own database, +
    +
  • + Register with Supabase. +
  • +
  • + Create a new database and note down the url and the key (that usually go into an + environment) +
  • +
  • Within the database, create a table and configure it to be public, promote changes in realtime and has no row protection: + +
    {
    +`-- 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);`
    +          }
    +
  • +
  • Fill in the url and key in the fields below and press "connect".
  • +
+
+

+ +

+

+ +

+

+ +

+ + + } + > + {(client: SupabaseClient) => } +
+
+
+ ); +}; + +export default App; diff --git a/packages/db-store/package.json b/packages/db-store/package.json new file mode 100644 index 000000000..e2889af6f --- /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 ", + "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 000000000..940eeda43 --- /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 & { id: number | string }; + +export type DbAdapterUpdate = { old?: Partial; new?: Partial }; + +export type DbAdapterAction = "insert" | "update" | "delete"; + +export type DbAdapterFilter = ( + ev: { action: DbAdapterAction } & DbAdapterUpdate, +) => boolean; + +export type DbAdapterOptions = { + client: SupabaseClient; + filter?: DbAdapterFilter; + table: string; +}; + +export type DbAdapter = { + insertSignal: () => DbAdapterUpdate | undefined; + updateSignal: () => DbAdapterUpdate | undefined; + deleteSignal: () => DbAdapterUpdate | undefined; + init: () => Promise<{ data?: Row[]; error?: unknown }>; + insert: (data: DbAdapterUpdate) => PromiseLike; + update: (data: DbAdapterUpdate) => PromiseLike; + delete: (data: DbAdapterUpdate) => PromiseLike; +}; + +export type DbStoreError = Error & { + data: DbAdapterUpdate; + action: DbAdapterAction; + server: boolean; +}; + +const createEventMemo = ( + eventBase: { readonly action: "insert" | "update" | "delete" }, + eventSignal: () => DbAdapterUpdate | undefined, + filter?: DbAdapterFilter, +) => + createMemo((prev: DbAdapterUpdate | undefined) => { + const data = eventSignal(); + const next = data ? Object.assign(eventBase, data) : undefined; + if (!next || (filter && !filter(next))) return prev; + return next; + }); + +const supabaseHandleError = + (data: DbAdapterUpdate, action: DbAdapterAction, server = false) => + ({ error }: { error: unknown | null }): Promise => + 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 = (opts: DbAdapterOptions): DbAdapter => { + const [insertSignal, setInsertSignal] = createSignal>(); + const [updateSignal, setUpdateSignal] = createSignal>(); + const [deleteSignal, setDeleteSignal] = createSignal>(); + const updateHandler = (ev: RealtimePostgresChangesPayload) => { + 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 = { + adapter: DbAdapter; + init?: Row[]; + equals?: (a: unknown, b: unknown) => boolean; + onError?: (err: DbStoreError) => void; +}; + +export const createDbStore = ( + opts: DbStoreOptions, +): [Store, SetStoreFunction, { refetch: () => void }] => { + const insertions = new Set>(); + const [dbStore, setDbStore] = createStore(opts.init || []); + const [dbInit, { refetch }] = createResource(opts.adapter.init); + const equals = opts.equals || ((a, b) => a === b); + const onError = (error: DbStoreError) => { + 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 = prev.reduce((map, item) => { + map.set(item, structuredClone(item)); + return map; + }, new Map()); + 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[] = []; + next.forEach(row => { + const prevRow = prevData.get(row); + if (prevRow) { + const updatedFields: Partial = {}; + 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); + } + } + }); + 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 })) + .catch(error => + supabaseHandleError( + { old: { id } as unknown as Partial }, + "delete", + )({ error }).catch(onError), + ); + } + }), + ...inserted.map((item?: Partial) => { + 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/test/index.test.ts b/packages/db-store/test/index.test.ts new file mode 100644 index 000000000..ef79f9ab3 --- /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 = { + eventType: "INSERT", + schema: "*", + old: {}, + new: { id: 1, data: "test" }, + table: "test", + commit_timestamp: new Date().toUTCString(), + errors: [], + }; + return new Promise(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 = { + eventType: "DELETE", + schema: "*", + new: {}, + old: { id: 1, data: "test" }, + table: "test", + commit_timestamp: new Date().toUTCString(), + errors: [], + }; + return new Promise(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 = { + eventType: "UPDATE", + schema: "*", + new: { data: "updated" }, + old: { id: 1 }, + table: "test", + commit_timestamp: new Date().toUTCString(), + errors: [], + }; + return new Promise(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((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((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((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((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((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((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((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((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((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((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((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((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 000000000..a850c8b4e --- /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) => void)[] = [] + +export const mockSupabaseClientEvents = { + insert: [] as Record[], + update: [] as Record[], + delete: [] as Record[] +}; + +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) => void) { mockSupabaseSubscribers.push(handler); return mockSupabaseClient; }, + subscribe: function() { return mockSupabaseClient; }, + unsubscribe: function() { mockSupabaseSubscribers.length = 0; return mockSupabaseClient; }, + insert: vi.fn(function(_row: Record) { return mockSupabaseResponses.insert; }), + delete: function(_row: { id: string | number }) { eqResponse = 'delete'; return mockSupabaseClient; }, + update: vi.fn(function(_row: Record) { eqResponse = 'update'; return mockSupabaseClient; }), + eq: vi.fn(function(_row: { id: string | number }) { return mockSupabaseResponses[eqResponse]; }), +} as unknown as SupabaseClient; + +export const mockSupabaseSendEvent = (ev: RealtimePostgresChangesPayload) => + mockSupabaseSubscribers.forEach(s => s(ev)); + +export const mockSupabaseResponses = { + insert: Promise.resolve({}), + delete: Promise.resolve({}), + update: Promise.resolve({}), +}; diff --git a/packages/db-store/tsconfig.json b/packages/db-store/tsconfig.json new file mode 100644 index 000000000..64f690fff --- /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 f16fe365c..f69ea2688 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': {}