Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/db-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pnpm add @solid-primitives/db-store
const [dbStore, setDbStore] = createDbStore({
adapter: supabaseAdapter(client),
table: "todos",
defaultFields: ['id', 'userid']
filter: ({ userid }) => userid === user.id,
onError: handleErrors,
});
Expand All @@ -42,9 +43,11 @@ The store is automatically initialized and optimistically updated both ways. Due
> [!NOTE]
> It can take some time for the database editor to show updates. They are processed a lot faster.

### Setting preliminary IDs
### Handling default fields

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.
The `id` field needs to be set by the database, so even if you set it, it needs to be overwritten in any case. There might be other fields that the server sets by default, e.g. a user ID. It is not required to set those for your rows manually; one can also treat its absence as a sign that an insertion is not yet done in the database.

By default, only 'id' is handled as default field. If you have additional default fields, you need to use the `defaultField` option to convey them; otherwise default fields not set by the client might break the reconciliation of newly added fields.

### Handling errors

Expand Down
42 changes: 42 additions & 0 deletions packages/db-store/dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Database setup

To create and set up the database for your own version of the todo list, use the following SQL statements in your supabase SQL editor:

```sql
create table todos (
id serial primary key,
task text,
user_id uuid references auth.users default auth.uid()
);

alter publication supabase_realtime add table todos;

create policy "realtime updates only for authenticated users"
on "realtime"."messages"
for select
to authenticated
using (true);

alter table "public"."todos" enable row level security;

create policy "Select only own tasks" on "public"."todos"
for select
to authenticated
using (((SELECT auth.uid() AS uid) = user_id));

create policy "Insert only own tasks" on "public"."todos"
for insert
to authenticated
with check (((SELECT auth.uid() AS uid) = user_id));

create policy "Delete only own tasks" on "public"."todos"
for delete
to authenticated
using (((SELECT auth.uid() AS uid) = user_id));

create policy "Update only own tasks" on "public"."todos"
for update
to authenticated
using (((SELECT auth.uid() AS uid) = user_id))
with check (((SELECT auth.uid() AS uid) = user_id));
```
110 changes: 51 additions & 59 deletions packages/db-store/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { Component, createSignal, For, Show } from "solid-js";
import { Component, createSignal, onMount, For, Show } from "solid-js";
import { createDbStore, supabaseAdapter, DbRow, DbStoreError } from "../src/index.js";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { AuthResponse, createClient, Session, SupabaseClient } from "@supabase/supabase-js";
import { reconcile } from "solid-js/store";

const TodoList = (props: { client: SupabaseClient }) => {
const TodoList = (props: { client: SupabaseClient, logout: () => void }) => {
const [error, setError] = createSignal<DbStoreError<DbRow>>();
(globalThis as any).supabaseClient = props.client;
const [todos, setTodos] = createDbStore({
adapter: supabaseAdapter({ client: props.client, table: "todos" }),
defaultFields: ['id', 'user_id'],
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 (
<>
<div>
<button onclick={props.logout}>logout</button>
<Show when={error()} keyed>
{err => (
<p class="text-red-600">
Expand Down Expand Up @@ -70,85 +73,74 @@ const TodoList = (props: { client: SupabaseClient }) => {
</button>
</li>
</ul>
</>
</div>
);
};

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));
// these are public keys that will end up in the client in any case:
const client =
createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_KEY
);
const [session, setSession] = createSignal<Session>();
const [error, setError] = createSignal('');
const handleAuthPromise = ({ error, data }: AuthResponse) => {
if (error) {
setError(error.toString())
} else {
setSession(data.session ?? undefined);
}
};
onMount(() => client.auth.refreshSession().then(handleAuthPromise));
const login = () => {
const email = (document.querySelector('[type="email"]') as HTMLInputElement | null)?.value;
const password = (document.querySelector('[type="password"]') as HTMLInputElement | null)?.value;
if (!email || !password) {
setError('please provide an email and password');
return;
}
client.auth.signInWithPassword({ email, password }).then(handleAuthPromise);
};
const register = () => {
const email = (document.querySelector('[type="email"]') as HTMLInputElement | null)?.value;
const password = (document.querySelector('[type="password"]') as HTMLInputElement | null)?.value;
if (!email || !password) {
setError('please provide an email and password');
return;
}
client.auth.signUp({ email, password }).then(handleAuthPromise);
}

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
when={session()}
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>
<Show when={error()}><p>{error()}</p></Show>
<p>
<label>
Client URL <input type="url" />
Email <input type="email" onInput={() => setError('')} />
</label>
</p>
<p>
<label>
Client Key <input type="password" />
Password <input type="password" />
</label>
</p>
<button class="btn" onClick={connect}>
Connect
</button>
<button class="btn" onClick={login}>sign in</button>
<button class="btn" onClick={register}>sign up</button>
</>
}
>
{(client: SupabaseClient) => <TodoList client={client} />}
<TodoList
client={client}
logout={() => { setSession(undefined); client.auth.signOut(); }}
/>
</Show>
</div>
</div>
Expand Down
18 changes: 10 additions & 8 deletions packages/db-store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
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 DbRow = Record<string, any>;

export type DbAdapterUpdate<Row extends DbRow> = { old?: Partial<Row>; new?: Partial<Row> };

Expand All @@ -21,11 +21,11 @@ export type DbAdapterFilter<Row extends DbRow> = (
ev: { action: DbAdapterAction } & DbAdapterUpdate<Row>,
) => boolean;

export type DbAdapterOptions<Row extends DbRow> = {
export type DbAdapterOptions<Row extends DbRow, Extras extends Record<string, any> = {}> = {
client: SupabaseClient;
filter?: DbAdapterFilter<Row>;
table: string;
};
} & Extras;

export type DbAdapter<Row extends DbRow> = {
insertSignal: () => DbAdapterUpdate<Row> | undefined;
Expand Down Expand Up @@ -69,7 +69,7 @@ const supabaseHandleError =
)
: Promise.resolve();

export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>): DbAdapter<Row> => {
export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row, { schema?: string }>): DbAdapter<Row> => {
const [insertSignal, setInsertSignal] = createSignal<DbAdapterUpdate<Row>>();
const [updateSignal, setUpdateSignal] = createSignal<DbAdapterUpdate<Row>>();
const [deleteSignal, setDeleteSignal] = createSignal<DbAdapterUpdate<Row>>();
Expand All @@ -84,7 +84,7 @@ export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>):
};
const channel = opts.client
.channel("schema-db-changes")
.on("postgres_changes", { event: "*", schema: "public" }, updateHandler)
.on("postgres_changes", { event: "*", schema: opts.schema || "public" }, updateHandler)
.subscribe();
onCleanup(() => channel.unsubscribe());
return {
Expand Down Expand Up @@ -119,6 +119,7 @@ export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>):
export type DbStoreOptions<Row extends DbRow> = {
adapter: DbAdapter<Row>;
init?: Row[];
defaultFields?: readonly string[];
equals?: (a: unknown, b: unknown) => boolean;
onError?: (err: DbStoreError<Row>) => void;
};
Expand All @@ -130,6 +131,7 @@ export const createDbStore = <Row extends DbRow>(
const [dbStore, setDbStore] = createStore<Row[]>(opts.init || []);
const [dbInit, { refetch }] = createResource(opts.adapter.init);
const equals = opts.equals || ((a, b) => a === b);
const defaultFields = opts.defaultFields || ['id'];
const onError = (error: DbStoreError<Row>) => {
if (typeof opts.onError === "function") {
opts.onError(error);
Expand All @@ -150,16 +152,16 @@ export const createDbStore = <Row extends DbRow>(
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))
if (Object.entries(inserted.new).some(([key, value]) => !defaultFields.includes(key) && row[key] !== value))
continue;
const index = untrack(() =>
dbStore.findIndex(cand =>
Object.entries(cand).every(([key, value]) => key === "id" || row[key] == value),
Object.entries(cand).every(([key, value]) => defaultFields.includes(key) || row[key] == value),
),
);
if (index !== -1) {
// @ts-ignore
setDbStore(index, "id", inserted.new.id);
setDbStore(index, inserted.new);
insertions.delete(row);
return;
}
Expand Down
2 changes: 2 additions & 0 deletions site/.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
# These variables are available to the public and can be accessed by anyone.

VITE_SITE_URL=https://solid-primitives.netlify.app
VITE_SUPABASE_URL=https://hpinwklbtszwebhaqjxr.supabase.co
VITE_SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhwaW53a2xidHN6d2ViaGFxanhyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzc1NTc1MTUsImV4cCI6MjA1MzEzMzUxNX0._D88mlVMZvkvipFkSwQBA8QZ9i0cl1tutWdYpM02cdI
NODE_VERSION=18.15.0
2 changes: 2 additions & 0 deletions site/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// declare import.meta.env
interface ImportMetaEnv {
readonly VITE_SITE_URL: string;
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_KEY: string;
}

interface ImportMeta {
Expand Down
Loading