Skip to content

Commit 31e1cfc

Browse files
authored
feat(db-store): default fields, authentication, schema option (#776)
2 parents 61140b9 + 8ae8975 commit 31e1cfc

File tree

6 files changed

+112
-69
lines changed

6 files changed

+112
-69
lines changed

packages/db-store/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pnpm add @solid-primitives/db-store
2929
const [dbStore, setDbStore] = createDbStore({
3030
adapter: supabaseAdapter(client),
3131
table: "todos",
32+
defaultFields: ['id', 'userid']
3233
filter: ({ userid }) => userid === user.id,
3334
onError: handleErrors,
3435
});
@@ -42,9 +43,11 @@ The store is automatically initialized and optimistically updated both ways. Due
4243
> [!NOTE]
4344
> It can take some time for the database editor to show updates. They are processed a lot faster.
4445
45-
### Setting preliminary IDs
46+
### Handling default fields
4647

47-
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.
48+
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.
49+
50+
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.
4851

4952
### Handling errors
5053

packages/db-store/dev/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Database setup
2+
3+
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:
4+
5+
```sql
6+
create table todos (
7+
id serial primary key,
8+
task text,
9+
user_id uuid references auth.users default auth.uid()
10+
);
11+
12+
alter publication supabase_realtime add table todos;
13+
14+
create policy "realtime updates only for authenticated users"
15+
on "realtime"."messages"
16+
for select
17+
to authenticated
18+
using (true);
19+
20+
alter table "public"."todos" enable row level security;
21+
22+
create policy "Select only own tasks" on "public"."todos"
23+
for select
24+
to authenticated
25+
using (((SELECT auth.uid() AS uid) = user_id));
26+
27+
create policy "Insert only own tasks" on "public"."todos"
28+
for insert
29+
to authenticated
30+
with check (((SELECT auth.uid() AS uid) = user_id));
31+
32+
create policy "Delete only own tasks" on "public"."todos"
33+
for delete
34+
to authenticated
35+
using (((SELECT auth.uid() AS uid) = user_id));
36+
37+
create policy "Update only own tasks" on "public"."todos"
38+
for update
39+
to authenticated
40+
using (((SELECT auth.uid() AS uid) = user_id))
41+
with check (((SELECT auth.uid() AS uid) = user_id));
42+
```

packages/db-store/dev/index.tsx

Lines changed: 51 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
import { Component, createSignal, For, Show } from "solid-js";
1+
import { Component, createSignal, onMount, For, Show } from "solid-js";
22
import { createDbStore, supabaseAdapter, DbRow, DbStoreError } from "../src/index.js";
3-
import { createClient, SupabaseClient } from "@supabase/supabase-js";
3+
import { AuthResponse, createClient, Session, SupabaseClient } from "@supabase/supabase-js";
44
import { reconcile } from "solid-js/store";
55

6-
const TodoList = (props: { client: SupabaseClient }) => {
6+
const TodoList = (props: { client: SupabaseClient, logout: () => void }) => {
77
const [error, setError] = createSignal<DbStoreError<DbRow>>();
8+
(globalThis as any).supabaseClient = props.client;
89
const [todos, setTodos] = createDbStore({
910
adapter: supabaseAdapter({ client: props.client, table: "todos" }),
11+
defaultFields: ['id', 'user_id'],
1012
onError: setError,
1113
});
1214
const [edit, setEdit] = createSignal<DbRow>();
1315
const done = (task: DbRow) => setTodos(reconcile(todos.filter(todo => todo !== task)));
1416
const add = (task: string) => setTodos(todos.length, { task });
1517
return (
16-
<>
18+
<div>
19+
<button onclick={props.logout}>logout</button>
1720
<Show when={error()} keyed>
1821
{err => (
1922
<p class="text-red-600">
@@ -70,85 +73,74 @@ const TodoList = (props: { client: SupabaseClient }) => {
7073
</button>
7174
</li>
7275
</ul>
73-
</>
76+
</div>
7477
);
7578
};
7679

7780
const App: Component = () => {
78-
const [client, setClient] = createSignal<SupabaseClient<any, "public", any>>();
79-
const connect = () => {
80-
const url = (document.querySelector('[type="url"]') as HTMLInputElement | null)?.value;
81-
const key = (document.querySelector('[type="password"]') as HTMLInputElement | null)?.value;
82-
url && key && setClient(createClient(url, key));
81+
// these are public keys that will end up in the client in any case:
82+
const client =
83+
createClient(
84+
import.meta.env.VITE_SUPABASE_URL,
85+
import.meta.env.VITE_SUPABASE_KEY
86+
);
87+
const [session, setSession] = createSignal<Session>();
88+
const [error, setError] = createSignal('');
89+
const handleAuthPromise = ({ error, data }: AuthResponse) => {
90+
if (error) {
91+
setError(error.toString())
92+
} else {
93+
setSession(data.session ?? undefined);
94+
}
95+
};
96+
onMount(() => client.auth.refreshSession().then(handleAuthPromise));
97+
const login = () => {
98+
const email = (document.querySelector('[type="email"]') as HTMLInputElement | null)?.value;
99+
const password = (document.querySelector('[type="password"]') as HTMLInputElement | null)?.value;
100+
if (!email || !password) {
101+
setError('please provide an email and password');
102+
return;
103+
}
104+
client.auth.signInWithPassword({ email, password }).then(handleAuthPromise);
83105
};
106+
const register = () => {
107+
const email = (document.querySelector('[type="email"]') as HTMLInputElement | null)?.value;
108+
const password = (document.querySelector('[type="password"]') as HTMLInputElement | null)?.value;
109+
if (!email || !password) {
110+
setError('please provide an email and password');
111+
return;
112+
}
113+
client.auth.signUp({ email, password }).then(handleAuthPromise);
114+
}
84115

85116
return (
86117
<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">
87118
<div class="wrapper-v">
88119
<h4>db-store-backed to-do list</h4>
89120
<Show
90-
when={client()}
91-
keyed
121+
when={session()}
92122
fallback={
93123
<>
94-
<details>
95-
<summary>To configure your own database,</summary>
96-
<ul class="list-disc">
97-
<li>
98-
Register with <a href="https://supabase.com">Supabase</a>.
99-
</li>
100-
<li>
101-
Create a new database and note down the url and the key (that usually go into an
102-
environment)
103-
</li>
104-
<li>
105-
Within the database, create a table and configure it to be public, promote
106-
changes in realtime and has no row protection:
107-
<pre>
108-
<code>{`-- Create table
109-
create table todos (
110-
id serial primary key,
111-
task text
112-
);
113-
-- Turn off row-level security
114-
alter table "todos"
115-
disable row level security;
116-
-- Allow anonymous access
117-
create policy "Allow anonymous access"
118-
on todos
119-
for select
120-
to anon
121-
using (true);`}</code>
122-
</pre>
123-
</li>
124-
<li>Fill in the url and key in the fields below and press "connect".</li>
125-
</ul>
126-
</details>
127-
<p>
128-
<label>
129-
DB
130-
<select>
131-
<option value="supabase">SupaBase</option>
132-
</select>
133-
</label>
134-
</p>
124+
<Show when={error()}><p>{error()}</p></Show>
135125
<p>
136126
<label>
137-
Client URL <input type="url" />
127+
Email <input type="email" onInput={() => setError('')} />
138128
</label>
139129
</p>
140130
<p>
141131
<label>
142-
Client Key <input type="password" />
132+
Password <input type="password" />
143133
</label>
144134
</p>
145-
<button class="btn" onClick={connect}>
146-
Connect
147-
</button>
135+
<button class="btn" onClick={login}>sign in</button>
136+
<button class="btn" onClick={register}>sign up</button>
148137
</>
149138
}
150139
>
151-
{(client: SupabaseClient) => <TodoList client={client} />}
140+
<TodoList
141+
client={client}
142+
logout={() => { setSession(undefined); client.auth.signOut(); }}
143+
/>
152144
</Show>
153145
</div>
154146
</div>

packages/db-store/src/index.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { createStore, reconcile, SetStoreFunction, Store, unwrap } from "solid-js/store";
1212
import { RealtimePostgresChangesPayload, SupabaseClient } from "@supabase/supabase-js";
1313

14-
export type DbRow = Record<string, any> & { id: number | string };
14+
export type DbRow = Record<string, any>;
1515

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

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

24-
export type DbAdapterOptions<Row extends DbRow> = {
24+
export type DbAdapterOptions<Row extends DbRow, Extras extends Record<string, any> = {}> = {
2525
client: SupabaseClient;
2626
filter?: DbAdapterFilter<Row>;
2727
table: string;
28-
};
28+
} & Extras;
2929

3030
export type DbAdapter<Row extends DbRow> = {
3131
insertSignal: () => DbAdapterUpdate<Row> | undefined;
@@ -69,7 +69,7 @@ const supabaseHandleError =
6969
)
7070
: Promise.resolve();
7171

72-
export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>): DbAdapter<Row> => {
72+
export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row, { schema?: string }>): DbAdapter<Row> => {
7373
const [insertSignal, setInsertSignal] = createSignal<DbAdapterUpdate<Row>>();
7474
const [updateSignal, setUpdateSignal] = createSignal<DbAdapterUpdate<Row>>();
7575
const [deleteSignal, setDeleteSignal] = createSignal<DbAdapterUpdate<Row>>();
@@ -84,7 +84,7 @@ export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>):
8484
};
8585
const channel = opts.client
8686
.channel("schema-db-changes")
87-
.on("postgres_changes", { event: "*", schema: "public" }, updateHandler)
87+
.on("postgres_changes", { event: "*", schema: opts.schema || "public" }, updateHandler)
8888
.subscribe();
8989
onCleanup(() => channel.unsubscribe());
9090
return {
@@ -119,6 +119,7 @@ export const supabaseAdapter = <Row extends DbRow>(opts: DbAdapterOptions<Row>):
119119
export type DbStoreOptions<Row extends DbRow> = {
120120
adapter: DbAdapter<Row>;
121121
init?: Row[];
122+
defaultFields?: readonly string[];
122123
equals?: (a: unknown, b: unknown) => boolean;
123124
onError?: (err: DbStoreError<Row>) => void;
124125
};
@@ -130,6 +131,7 @@ export const createDbStore = <Row extends DbRow>(
130131
const [dbStore, setDbStore] = createStore<Row[]>(opts.init || []);
131132
const [dbInit, { refetch }] = createResource(opts.adapter.init);
132133
const equals = opts.equals || ((a, b) => a === b);
134+
const defaultFields = opts.defaultFields || ['id'];
133135
const onError = (error: DbStoreError<Row>) => {
134136
if (typeof opts.onError === "function") {
135137
opts.onError(error);
@@ -150,16 +152,16 @@ export const createDbStore = <Row extends DbRow>(
150152
on(opts.adapter.insertSignal, inserted => {
151153
if (!inserted?.new?.id) return;
152154
for (const row of insertions.values()) {
153-
if (Object.entries(inserted.new).some(([key, value]) => key !== "id" && row[key] !== value))
155+
if (Object.entries(inserted.new).some(([key, value]) => !defaultFields.includes(key) && row[key] !== value))
154156
continue;
155157
const index = untrack(() =>
156158
dbStore.findIndex(cand =>
157-
Object.entries(cand).every(([key, value]) => key === "id" || row[key] == value),
159+
Object.entries(cand).every(([key, value]) => defaultFields.includes(key) || row[key] == value),
158160
),
159161
);
160162
if (index !== -1) {
161163
// @ts-ignore
162-
setDbStore(index, "id", inserted.new.id);
164+
setDbStore(index, inserted.new);
163165
insertions.delete(row);
164166
return;
165167
}

site/.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
# These variables are available to the public and can be accessed by anyone.
33

44
VITE_SITE_URL=https://solid-primitives.netlify.app
5+
VITE_SUPABASE_URL=https://hpinwklbtszwebhaqjxr.supabase.co
6+
VITE_SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhwaW53a2xidHN6d2ViaGFxanhyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzc1NTc1MTUsImV4cCI6MjA1MzEzMzUxNX0._D88mlVMZvkvipFkSwQBA8QZ9i0cl1tutWdYpM02cdI
57
NODE_VERSION=18.15.0

site/src/env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
// declare import.meta.env
44
interface ImportMetaEnv {
55
readonly VITE_SITE_URL: string;
6+
readonly VITE_SUPABASE_URL: string;
7+
readonly VITE_SUPABASE_KEY: string;
68
}
79

810
interface ImportMeta {

0 commit comments

Comments
 (0)