mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
41 files changed
+2361
-649
No files matched your search
@@ -31,7 +31,7 @@ Find the source code on [GitHub](https://github.com/supabase/supabase-js).
|
||||
Or via CDN
|
||||
|
||||
```js
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
||||
//or
|
||||
<script src="https://unpkg.com/@supabase/supabase-js"></script>
|
||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
||||
```
|
||||
@@ -76,9 +76,9 @@ async function getMovies() {
|
||||
return await supabase.from('movies').select('id, title, actors(*)')
|
||||
}
|
||||
|
||||
type actors = Database['public']['Tables']['actors']['Row']
|
||||
type Actors = Database['public']['Tables']['actors']['Row']
|
||||
type MoviesResponse = Awaited<ReturnType<typeof getMovies>>
|
||||
type MoviesResponseSuccess = MoviesResponse['data'] & {
|
||||
actors: actors[]
|
||||
actors: Actors[]
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ Find the source code on [GitHub](https://github.com/supabase/supabase-js).
|
||||
Or via CDN
|
||||
|
||||
```js
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@1"></script>
|
||||
//or
|
||||
<script src="https://unpkg.com/@supabase/supabase-js"></script>
|
||||
<script src="https://unpkg.com/@supabase/supabase-js@1"></script>
|
||||
```
|
||||
@@ -104,7 +104,7 @@ Edit your `+layout.svelte` file and set up the client-side.
|
||||
|
||||
### Set up hooks
|
||||
|
||||
The `hooks.ts` file is where the heavy lifting of this library happens:
|
||||
The `hooks.server.ts` file is where the heavy lifting of this library happens:
|
||||
|
||||
```ts title="src/hooks.server.ts"
|
||||
// make sure the supabase instance is initialized on the server
|
||||
|
||||
@@ -348,6 +348,24 @@ Once pushed, check that the migration version is up to date for both local and r
|
||||
supabase migration list
|
||||
```
|
||||
|
||||
### Permission denied on db remote commit
|
||||
|
||||
If you have been using Supabase hosted projects for a long time, you might encounter the following permission error when executing `db remote commit`.
|
||||
|
||||
```bash
|
||||
Error: Error running pg_dump on remote database: pg_dump: error: query failed: ERROR: permission denied for table _type
|
||||
|
||||
pg_dump: error: query was: LOCK TABLE "graphql"."_type" IN ACCESS SHARE MODE
|
||||
```
|
||||
|
||||
To resolve this error, you need to grant `postgres` role permissions to `graphql` schema. You can do that by running the following query from Supabase dashboard's SQL Editor.
|
||||
|
||||
```sql
|
||||
grant all on all tables in schema graphql to postgres, anon, authenticated, service_role;
|
||||
grant all on all functions in schema graphql to postgres, anon, authenticated, service_role;
|
||||
grant all on all sequences in schema graphql to postgres, anon, authenticated, service_role;
|
||||
```
|
||||
|
||||
### Permission denied on db push
|
||||
|
||||
If you created a table through Supabase dashboard, and your new migration script contains `ALTER TABLE` statements, you might run into permission error when applying them on staging or production databases.
|
||||
@@ -358,12 +376,14 @@ ERROR: must be owner of table employees (SQLSTATE 42501); while executing migrat
|
||||
|
||||
This is because tables created through Supabase dashboard are owned by `supabase_admin` role while the migration scripts executed through CLI are under `postgres` role.
|
||||
|
||||
One way to solve this is to grant `postgres` role additional privileges through the SQL Editor available on Supabase dashboard. For example, the following command grants postgres permissions to alter any table in the public schema.
|
||||
One way to solve this is to reassign the owner of those tables to `postgres` role. For example, if your table is named `users` in the public schema, you can run the following command to reassign owner.
|
||||
|
||||
```sql
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO postgres;
|
||||
ALTER TABLE users OWNER TO postgres;
|
||||
```
|
||||
|
||||
Apart from tables, you also need to reassign owner of other entities using their respective commands, including [types](https://www.postgresql.org/docs/current/sql-alterschema.html), [functions](https://www.postgresql.org/docs/current/sql-alterroutine.html), and [schemas](https://www.postgresql.org/docs/current/sql-altertype.html).
|
||||
|
||||
### Rebasing new migrations
|
||||
|
||||
Sometimes your teammate may merge a new migration file to git main branch, and now you need to rebase your local schema changes on top.
|
||||
|
||||
@@ -62,7 +62,7 @@ This project comes with TypeScript configured and has the following structure.
|
||||
### Configuring the project to use PostgreSQL
|
||||
|
||||
Go ahead and delete the `prisma/dev.db` file because we will be switching to PostgreSQL.
|
||||
Next, inside the `prisma/.env` file, update the value of the `DATABASE_URL` variable to the connection string you got in **step 3**. The URL might look as follows:
|
||||
Next, inside the `prisma/.env` file, update the value of the `DATABASE_URL` variable to the connection string you got in **step 1**. The URL might look as follows:
|
||||
|
||||
```env
|
||||
# prisma/.env
|
||||
@@ -122,7 +122,7 @@ This will create a `prisma/migrations` folder inside your `prisma` directory and
|
||||
If you’re working in a serverless environment (for example Node.js functions hosted on AWS Lambda, Vercel or Netlify Functions), you need to set up [connection pooling](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#serverless-environments-faas) using a tool like [PgBouncer](https://www.pgbouncer.org/). That’s because every function invocation may result in a [new connection to the database](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#the-serverless-challenge). Supabase [supports connection management using PgBouncer](https://supabase.io/blog/2021/04/02/supabase-pgbouncer#what-is-connection-pooling) and are enabled by default.
|
||||
Go to the **Database** page from the sidebar in the Supabase dashboard and navigate to **connection pool** settings
|
||||

|
||||
When running migrations you need to use the non pooled connection URL (like the one we used in **step 4**). However, when deploying your app, you’ll use the pooled connection URL and add the `?pgbouncer=true` flag to the PostgreSQL connection URL. To minimize the number of concurrent connections, setting the `connection_limit` to `1` is also recommended. So the URL might look as follows:
|
||||
When running migrations you need to use the non pooled connection URL (like the one we used in **step 1**). However, when deploying your app, you’ll use the pooled connection URL and add the `?pgbouncer=true` flag to the PostgreSQL connection URL. To minimize the number of concurrent connections, setting the `connection_limit` to `1` is also recommended. So the URL might look as follows:
|
||||
|
||||
```env
|
||||
# prisma/.env
|
||||
|
||||
@@ -33,7 +33,7 @@ If you want to do it yourself, let's get started!
|
||||
|
||||
### GitHub
|
||||
|
||||
Whenever you get stuck at any point, take a look at [this repo](https://github.com/angular-supa/supabase-angular-user-management).
|
||||
Whenever you get stuck at any point, take a look at [this repo](https://github.com/supabase/examples/tree/main/supabase-js-v2/user-management/angular-user-management).
|
||||
|
||||
## Project set up
|
||||
|
||||
@@ -180,281 +180,365 @@ export const environment = {
|
||||
Now that we have the API credentials in place, let's create a **SupabaseService** with `ng g s supabase` to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
|
||||
```ts title="src/app/supabase.service.ts"
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
AuthChangeEvent,
|
||||
AuthSession,
|
||||
createClient,
|
||||
Session,
|
||||
SupabaseClient,
|
||||
} from '@supabase/supabase-js'
|
||||
import { environment } from '../environments/environment'
|
||||
User,
|
||||
} from '@supabase/supabase-js';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Database } from 'src/schema';
|
||||
|
||||
export interface Profile {
|
||||
username: string
|
||||
website: string
|
||||
avatar_url: string
|
||||
id?: string;
|
||||
username: string;
|
||||
website: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SupabaseService {
|
||||
private supabase: SupabaseClient
|
||||
private supabase: SupabaseClient;
|
||||
_session: AuthSession | null = null;
|
||||
|
||||
constructor() {
|
||||
this.supabase = createClient(
|
||||
environment.supabaseUrl,
|
||||
environment.supabaseKey
|
||||
)
|
||||
}
|
||||
|
||||
get user() {
|
||||
return this.supabase.auth.user()
|
||||
);
|
||||
}
|
||||
|
||||
get session() {
|
||||
return this.supabase.auth.session()
|
||||
this.supabase.auth.getSession().then(({ data }) => {
|
||||
this._session = data.session;
|
||||
})
|
||||
return this._session;
|
||||
}
|
||||
|
||||
get profile() {
|
||||
profile(user: User) {
|
||||
return this.supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', this.user?.id)
|
||||
.single()
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
}
|
||||
|
||||
authChanges(
|
||||
callback: (event: AuthChangeEvent, session: Session | null) => void
|
||||
) {
|
||||
return this.supabase.auth.onAuthStateChange(callback)
|
||||
return this.supabase.auth.onAuthStateChange(callback);
|
||||
}
|
||||
|
||||
signIn(email: string) {
|
||||
return this.supabase.auth.signIn({ email })
|
||||
return this.supabase.auth.signInWithOtp({ email });
|
||||
}
|
||||
|
||||
signOut() {
|
||||
return this.supabase.auth.signOut()
|
||||
return this.supabase.auth.signOut();
|
||||
}
|
||||
|
||||
updateProfile(profile: Profile) {
|
||||
const update = {
|
||||
...profile,
|
||||
id: this.user?.id,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
return this.supabase.from('profiles').upsert(update, {
|
||||
returning: 'minimal', // Don't return the value after inserting
|
||||
})
|
||||
return this.supabase.from('profiles').upsert(update);
|
||||
}
|
||||
|
||||
downLoadImage(path: string) {
|
||||
return this.supabase.storage.from('avatars').download(path)
|
||||
return this.supabase.storage.from('avatars').download(path);
|
||||
}
|
||||
|
||||
uploadAvatar(filePath: string, file: File) {
|
||||
return this.supabase.storage.from('avatars').upload(filePath, file)
|
||||
return this.supabase.storage.from('avatars').upload(filePath, file);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And one optional step is to update the CSS file `src/index.css` to make the app look nice.
|
||||
You can find the full contents of this file [here](https://raw.githubusercontent.com/angular-supa/supabase-angular-user-management/main/src/styles.css).
|
||||
Optionally, update [src/styles.css](https://raw.githubusercontent.com/supabase/examples/main/supabase-js-v2/user-management/angular-user-management/src/styles.css) to style the app.
|
||||
|
||||
### Set up a Login component
|
||||
|
||||
Let's set up an Angular component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
Create an **AuthComponent** with `ng g c auth` Angular CLI command.
|
||||
|
||||
```ts title="src/app/auth.component.ts"
|
||||
import { Component } from '@angular/core'
|
||||
import { SupabaseService } from './supabase.service'
|
||||
```ts title="src/app/auth/auth.component.ts"
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
template: `
|
||||
<div class="row flex-center flex">
|
||||
<form class="col-6 form-widget">
|
||||
<h1 class="header">Supabase + Angular</h1>
|
||||
<p class="description">Sign in via magic link with your email below</p>
|
||||
<div>
|
||||
<input
|
||||
#input
|
||||
class="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
(click)="handleLogin(input.value)"
|
||||
class="button block"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ loading ? 'Loading' : 'Send magic link' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './auth.component.html',
|
||||
styleUrls: ['./auth.component.css'],
|
||||
})
|
||||
export class AuthComponent {
|
||||
loading = false
|
||||
export class AuthComponent implements OnInit {
|
||||
loading = false;
|
||||
|
||||
constructor(private readonly supabase: SupabaseService) {}
|
||||
signInForm = this.formBuilder.group({
|
||||
email: '',
|
||||
});
|
||||
|
||||
async handleLogin(input: string) {
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private readonly formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
try {
|
||||
this.loading = true
|
||||
await this.supabase.signIn(input)
|
||||
alert('Check your email for the login link!')
|
||||
this.loading = true;
|
||||
const email = this.signInForm.value.email as string;
|
||||
const { error } = await this.supabase.signIn(email);
|
||||
if (error) throw error;
|
||||
alert('Check your email for the login link!');
|
||||
} catch (error) {
|
||||
alert(error.error_description || error.message)
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.signInForm.reset();
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html title="src/app/auth/auth.component.html"
|
||||
<div class="row flex-center flex">
|
||||
<div class="col-6 form-widget" aria-live="polite">
|
||||
<h1 class="header">Supabase + Angular</h1>
|
||||
<p class="description">Sign in via magic link with your email below</p>
|
||||
<form [formGroup]="signInForm" (ngSubmit)="onSubmit()" class="form-widget">
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
formControlName="email"
|
||||
class="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="button block"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ loading ? 'Loading' : 'Send magic link' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div
|
||||
```
|
||||
|
||||
### Account page
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
Create an **AccountComponent** with `ng g c account` Angular CLI command.
|
||||
Users also need a way to edit their profile details and manage their accounts after signing in.
|
||||
Create an **AccountComponent** with the `ng g c account` Angular CLI command.
|
||||
|
||||
```ts title="src/app/account.component.ts"
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { Profile, SupabaseService } from './supabase.service'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
```ts title="src/app/account/account.component.ts"
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { AuthSession } from '@supabase/supabase-js';
|
||||
import { Profile, SupabaseService } from '../supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
template: `
|
||||
<div class="form-widget">
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="text" [value]="session?.user?.email" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label for="username">Name</label>
|
||||
<input
|
||||
#username
|
||||
id="username"
|
||||
type="text"
|
||||
[value]="profile?.username ?? ''"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
#website
|
||||
id="website"
|
||||
type="url"
|
||||
[value]="profile?.website ?? ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="button primary block"
|
||||
(click)="updateProfile(username.value, website.value)"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ loading ? 'Loading ...' : 'Update' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="button block" (click)="signOut()">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.css'],
|
||||
})
|
||||
export class AccountComponent implements OnInit {
|
||||
loading = false
|
||||
profile: Profile | undefined
|
||||
loading = false;
|
||||
profile!: Profile;
|
||||
|
||||
@Input() session: Session | undefined
|
||||
@Input()
|
||||
session!: AuthSession;
|
||||
|
||||
constructor(private readonly supabase: SupabaseService) {}
|
||||
updateProfileForm = this.formBuilder.group({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.getProfile()
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.getProfile();
|
||||
|
||||
const { username, website, avatar_url } = this.profile;
|
||||
this.updateProfileForm.patchValue({
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile() {
|
||||
try {
|
||||
this.loading = true
|
||||
let { data: profile, error, status } = await this.supabase.profile
|
||||
this.loading = true;
|
||||
const { user } = this.session;
|
||||
let { data: profile, error, status } = await this.supabase.profile(user);
|
||||
|
||||
if (error && status !== 406) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
this.profile = profile
|
||||
this.profile = profile;
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
username: string,
|
||||
website: string,
|
||||
avatar_url: string = ''
|
||||
) {
|
||||
async updateProfile(): Promise<void> {
|
||||
try {
|
||||
this.loading = true
|
||||
await this.supabase.updateProfile({ username, website, avatar_url })
|
||||
this.loading = true;
|
||||
const { user } = this.session;
|
||||
|
||||
const username = this.updateProfileForm.value.username as string;
|
||||
const website = this.updateProfileForm.value.website as string;
|
||||
const avatar_url = this.updateProfileForm.value.avatar_url as string;
|
||||
|
||||
const { error } = await this.supabase.updateProfile({
|
||||
id: user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
});
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.supabase.signOut()
|
||||
await this.supabase.signOut();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html title="src/app/account/account.component.html"
|
||||
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="text" [value]="session.user.email" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label for="username">Name</label>
|
||||
<input
|
||||
formControlName="username"
|
||||
id="username"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
formControlName="website"
|
||||
id="website"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="button primary block"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ loading ? 'Loading ...' : 'Update' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="button block" (click)="signOut()">Sign Out</button>
|
||||
</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update **AppComponent**:
|
||||
|
||||
```ts title="src/app/app.component.ts"
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { SupabaseService } from './supabase.service'
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { SupabaseService } from './supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: `
|
||||
<div class="container" style="padding: 50px 0 100px 0">
|
||||
<app-account *ngIf="session; else auth" [session]="session"></app-account>
|
||||
<ng-template #auth>
|
||||
<app-auth></app-auth>
|
||||
</ng-template>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
session = this.supabase.session
|
||||
title = 'angular-user-management';
|
||||
|
||||
session = this.supabase.session;
|
||||
|
||||
constructor(private readonly supabase: SupabaseService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.supabase.authChanges((_, session) => (this.session = session))
|
||||
this.supabase.authChanges((_, session) => (this.session = session));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html title="src/app/app.component.html"
|
||||
<div class="container" style="padding: 50px 0 100px 0">
|
||||
<app-account *ngIf="session; else auth" [session]="session"></app-account>
|
||||
<ng-template #auth>
|
||||
<app-auth></app-auth>
|
||||
</ng-template>
|
||||
</div>
|
||||
```
|
||||
|
||||
`app.module.ts` also needs to be modified to include the `ReactiveFormsModule` from the `@angular/forms` package.
|
||||
|
||||
```ts title="src/app/app.module.ts"
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthComponent } from './auth/auth.component';
|
||||
import { AccountComponent } from './account/account.component';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { AvatarComponent } from './avatar/avatar.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
AuthComponent,
|
||||
AccountComponent,
|
||||
AvatarComponent,
|
||||
],
|
||||
imports: [BrowserModule, ReactiveFormsModule],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
```bash
|
||||
@@ -474,110 +558,141 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
Create an **AvatarComponent** with `ng g c avatar` Angular CLI command.
|
||||
|
||||
```ts title="src/app/avatar.component.ts"
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { SupabaseService } from './supabase.service'
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
|
||||
```ts title="src/app/avatar/avatar.component.ts"
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { SafeResourceUrl, DomSanitizer } from '@angular/platform-browser';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-avatar',
|
||||
template: `
|
||||
<div>
|
||||
<img
|
||||
*ngIf="_avatarUrl"
|
||||
[src]="_avatarUrl"
|
||||
alt="Avatar"
|
||||
class="avatar image"
|
||||
style="height: 150px; width: 150px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!_avatarUrl"
|
||||
class="avatar no-image"
|
||||
style="height: 150px; width: 150px"
|
||||
></div>
|
||||
<div style="width: 150px">
|
||||
<label class="button primary block" for="single">
|
||||
{{ uploading ? 'Uploading ...' : 'Upload' }}
|
||||
</label>
|
||||
<input
|
||||
style="visibility: hidden;position: absolute"
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
(change)="uploadAvatar($event)"
|
||||
[disabled]="uploading"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './avatar.component.html',
|
||||
styleUrls: ['./avatar.component.css'],
|
||||
})
|
||||
export class AvatarComponent {
|
||||
_avatarUrl: SafeResourceUrl | undefined
|
||||
uploading = false
|
||||
export class AvatarComponent implements OnInit {
|
||||
_avatarUrl: SafeResourceUrl | undefined;
|
||||
uploading = false;
|
||||
|
||||
@Input()
|
||||
set avatarUrl(url: string | undefined) {
|
||||
set avatarUrl(url: string | null) {
|
||||
if (url) {
|
||||
this.downloadImage(url)
|
||||
this.downloadImage(url);
|
||||
}
|
||||
}
|
||||
|
||||
@Output() upload = new EventEmitter<string>()
|
||||
@Output() upload = new EventEmitter<string>();
|
||||
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private readonly dom: DomSanitizer
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
async downloadImage(path: string) {
|
||||
try {
|
||||
const { data } = await this.supabase.downLoadImage(path)
|
||||
const { data } = await this.supabase.downLoadImage(path);
|
||||
if (data instanceof Blob) {
|
||||
this._avatarUrl = this.dom.bypassSecurityTrustResourceUrl(
|
||||
URL.createObjectURL(data)
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading image: ', error.message)
|
||||
if (error instanceof Error) {
|
||||
console.error('Error downloading image: ', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAvatar(event: any) {
|
||||
try {
|
||||
this.uploading = true
|
||||
this.uploading = true;
|
||||
if (!event.target.files || event.target.files.length === 0) {
|
||||
throw new Error('You must select an image to upload.')
|
||||
throw new Error('You must select an image to upload.');
|
||||
}
|
||||
|
||||
const file = event.target.files[0]
|
||||
const fileExt = file.name.split('.').pop()
|
||||
const fileName = `${Math.random()}.${fileExt}`
|
||||
const filePath = `${fileName}`
|
||||
const file = event.target.files[0];
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const filePath = `${Math.random()}.${fileExt}`;
|
||||
|
||||
await this.supabase.uploadAvatar(filePath, file)
|
||||
this.upload.emit(filePath)
|
||||
await this.supabase.uploadAvatar(filePath, file);
|
||||
this.upload.emit(filePath);
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
}
|
||||
} finally {
|
||||
this.uploading = false
|
||||
this.uploading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html title="src/app/avatar/avatar.component.html"
|
||||
<div>
|
||||
<img
|
||||
*ngIf="_avatarUrl"
|
||||
[src]="_avatarUrl"
|
||||
alt="Avatar"
|
||||
class="avatar image"
|
||||
style="height: 150px; width: 150px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!_avatarUrl"
|
||||
class="avatar no-image"
|
||||
style="height: 150px; width: 150px"
|
||||
></div>
|
||||
<div style="width: 150px">
|
||||
<label class="button primary block" for="single">
|
||||
{{ uploading ? 'Uploading ...' : 'Upload' }}
|
||||
</label>
|
||||
<input
|
||||
style="visibility: hidden;position: absolute"
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
(change)="uploadAvatar($event)"
|
||||
[disabled]="uploading"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget on top of the **AccountComponent** html template:
|
||||
|
||||
```ts title="src/app/account.component.ts"
|
||||
template: `
|
||||
<app-avatar
|
||||
[avatarUrl]="this.profile?.avatar_url"
|
||||
(upload)="updateProfile(username.value, website.value, $event)">
|
||||
</app-avatar>
|
||||
```html title="src/app/account.component.html"
|
||||
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
|
||||
<app-avatar
|
||||
[avatarUrl]="this.avatarUrl"
|
||||
(upload)="updateAvatar($event)">
|
||||
</app-avatar>
|
||||
<!-- input fields -->
|
||||
</form>
|
||||
```
|
||||
|
||||
<!-- input fields -->
|
||||
`
|
||||
And add an `updateAvatar` function along with an `avatarUrl` getter to the **AccountComponent** typescript file:
|
||||
|
||||
```ts title="src/app/account.component.ts"
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.css'],
|
||||
})
|
||||
export class AccountComponent implements OnInit {
|
||||
// ...
|
||||
get avatarUrl() {
|
||||
return this.updateProfileForm.value.avatar_url as string;
|
||||
}
|
||||
|
||||
async updateAvatar(event: string): Promise<void> {
|
||||
this.updateProfileForm.patchValue({
|
||||
avatar_url: event,
|
||||
});
|
||||
await this.updateProfile();
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function gen(
|
||||
apiUrl: string
|
||||
) {
|
||||
const specRaw = fs.readFileSync(inputFileName, 'utf8')
|
||||
const spec = JSON.parse(specRaw)
|
||||
const spec = JSON.parse(specRaw) as any
|
||||
// console.log('spec', spec)
|
||||
|
||||
switch (spec.openapi || spec.swagger) {
|
||||
|
||||
@@ -6,8 +6,7 @@ import template from './templates/CliTemplate'
|
||||
import type { CliSpec } from './types/CliSpec'
|
||||
|
||||
export default async function gen(inputFileName: string, outputDir: string) {
|
||||
const spec = yaml.load(fs.readFileSync(inputFileName, 'utf8'))
|
||||
// console.log('spec', spec)
|
||||
const spec = yaml.load(fs.readFileSync(inputFileName, 'utf8')) as any
|
||||
|
||||
switch (spec.clispec) {
|
||||
case '001':
|
||||
|
||||
@@ -12,7 +12,7 @@ type Section = {
|
||||
}
|
||||
|
||||
export default async function gen(inputFileName: string, outputDir: string) {
|
||||
const spec = yaml.load(fs.readFileSync(inputFileName, 'utf8'))
|
||||
const spec = yaml.load(fs.readFileSync(inputFileName, 'utf8')) as any
|
||||
// console.log('spec', spec)
|
||||
|
||||
switch (spec.configspec) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { writeToDisk } from './helpers'
|
||||
import type { SdkSpec } from './types/SdkSpec'
|
||||
|
||||
export default async function gen(inputFileName: string, outputDir: string) {
|
||||
const spec = yaml.load(fs.readFileSync(inputFileName, 'utf8'))
|
||||
const spec = yaml.load(fs.readFileSync(inputFileName, 'utf8')) as any
|
||||
// console.log('spec', spec)
|
||||
|
||||
switch (spec.sdkspec) {
|
||||
|
||||
@@ -28,9 +28,9 @@ Find the source code on [GitHub](https://github.com/supabase/supabase-js).
|
||||
|
||||
Or via CDN
|
||||
```js
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
||||
//or
|
||||
<script src="https://unpkg.com/@supabase/supabase-js"></script>
|
||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
@@ -28,9 +28,9 @@ Find the source code on [GitHub](https://github.com/supabase/supabase-js).
|
||||
|
||||
Or via CDN
|
||||
```js
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
||||
//or
|
||||
<script src="https://unpkg.com/@supabase/supabase-js"></script>
|
||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
---
|
||||
title: 'Postgres Full Text Search vs the rest'
|
||||
description: 'Comparing one of the most popular Postgres features against alternatives'
|
||||
author: victor
|
||||
image: postgres-fts-vs-the-rest/postgres-full-text-search.jpg
|
||||
thumb: postgres-fts-vs-the-rest/postgres-full-text-search.jpg
|
||||
tags:
|
||||
- postgres
|
||||
date: '2022-10-14'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
Postgres is one of the best F/OSS databases partly because of its immense feature-set.
|
||||
|
||||
One of my favorite Postgres features is [Full Text Search](https://supabase.com/docs/guides/database/full-text-search) (FTS). Search is a common requirement of applications. Well-known search engines like [Solr](https://solr.apache.org/) and [ElasticSearch](https://www.elastic.co/elasticsearch) are often a first choice, but with Postgres in your stack you've got a great chance for [Pareto improvement](https://en.wikipedia.org/wiki/Pareto_principle) at low complexity cost.
|
||||
|
||||
Many projects function just fine with Postgres Full Text Search and other [built-in](https://www.postgresql.org/docs/current/contrib.html) extensions like [trigram search (`pg_trgm`)](https://www.postgresql.org/docs/current/pgtrgm.html). [GitLab's blog has a great article](https://about.staging.gitlab.com/blog/2016/03/18/fast-search-using-postgresql-trigram-indexes/) on their use of Trigram indices to speed up search.
|
||||
|
||||
## Search Engines: The Next Generation
|
||||
|
||||
These days, the debate isn't _just_ Postgres versus Solr or ElasticSearch, a new generation of F/OSS search engines has arrived. To name a few:
|
||||
|
||||
- [MeiliSearch](https://www.meilisearch.com/)
|
||||
- [OpenSearch](https://opensearch.org/)
|
||||
- [SQLite FTS](https://www.sqlite.org/fts5.html)
|
||||
- [Typesense](https://typesense.org/)
|
||||
|
||||
Well OK, some of these engines aren't _quite_ new; SQLite is stable and has supported the search use-case for a while and OpenSearch is a fork of ElasticSearch. While not new, both are worth comparing as choices in front of engineers today (spoiler alert: we're going to compare them).
|
||||
|
||||
These search engines are all impressive, but it's hard to evaluate these options in a `VACUUM`.
|
||||
|
||||
If Postgres is already your main database, then it's probably easier to integrate than anything else. But would it be _better_ to take on more complexity by choosing a dedicated search engine?
|
||||
|
||||
While search is an endlessly complicated problem and all of these tools are configurable, a comparison of minimal setup and tuning should yield some interesting insights.
|
||||
|
||||
Before we get into it though, a _brief_ primer on what "Full Text Search" _means_, in Postgres land.
|
||||
|
||||
## A whirlwind tour through FTS in Postgres
|
||||
|
||||
If we want to compare other solutions to Postgres FTS, we'd better review how to set up and use [Postgres FTS](https://www.postgresql.org/docs/current/textsearch.html)!
|
||||
|
||||
### Wrangling your existing data
|
||||
|
||||
Imagine you have some information to search through, stored in your main database.
|
||||
|
||||
Maybe a `movies` table with a structure like this:
|
||||
|
||||
```sql
|
||||
create table movies (
|
||||
id bigint primary key generated by default as identity,
|
||||
title text not null,
|
||||
original_title text not null,
|
||||
overview text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
```
|
||||
|
||||
While you're inserting data you might perform `INSERTs` like this one:
|
||||
|
||||
```sql
|
||||
insert into movies (
|
||||
title,
|
||||
original_title,
|
||||
overview
|
||||
)
|
||||
values
|
||||
(
|
||||
"Avengers: Age of Ultron",
|
||||
"Avengers: Age of Ultron",
|
||||
"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth’s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron emerges, it is up to The Avengers to stop him from enacting his terrible plans, and soon uneasy alliances and unexpected action pave the way for an epic and unique global adventure.",
|
||||
);
|
||||
```
|
||||
|
||||
### Basic searching with select and like
|
||||
|
||||
With nothing but the raw data in your database, we can actually do some _basic_ searching using `select` and comparison tools like `like`.
|
||||
|
||||
```sql
|
||||
select
|
||||
*
|
||||
from
|
||||
movies
|
||||
where
|
||||
title like '%Avengers%'
|
||||
or
|
||||
overview like '%Avengers%';
|
||||
```
|
||||
|
||||
The `LIKE` [operator](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE) as used in the query above will match _only_ the term "Avengers", but it will match it in any part of a given row's `title` or `overview`.
|
||||
|
||||
We can hack around this basic functionality and make it a bit more robust:
|
||||
|
||||
- Storing the text as [case insensitive text (`citext`)](https://www.postgresql.org/docs/current/citext.html)
|
||||
- Combining the text that will be searched into one column
|
||||
- Using a [GIN index](https://www.postgresql.org/docs/14/indexes-types.html#INDEXES-TYPES-GIN)
|
||||
|
||||
Our "v0" would be better, but using `like` isn't quite what people mean when they ask for _robust_ full text search. **Not a bad first version, but Postgres can do much better.**
|
||||
|
||||
### Robust Full Text Search with Postgres
|
||||
|
||||
Postgres has built-in [Full Text Search](https://www.postgresql.org/docs/current/textsearch.html) which we can use instead of cobbling together our own with `like`.
|
||||
|
||||
Luckily for us, [the Postgres FTS documentation](https://www.postgresql.org/docs/current/textsearch.html) is quite extensive, containing lots of information on the all-important [Postgres search data types: `tsvector` and `tsquery`](https://www.postgresql.org/docs/current/datatype-textsearch.html).
|
||||
|
||||
State of the art search engines like [Solr](https://solr.apache.org/) do things to make searches work well:
|
||||
|
||||
- [Stemming](https://en.wikipedia.org/wiki/Stemming) ("jumped", "jumper" -> "jump")
|
||||
- [Lemmatization](https://en.wikipedia.org/wiki/lemmatization) ("better" -> "good")
|
||||
- Result Ranking configuration
|
||||
- Search by [edit distance](https://en.wikipedia.org/wiki/Levenshtein_distance)
|
||||
- Vector based similarity (which you might use for a basic recommendation system)
|
||||
|
||||
Postgres FTS does not do _all_ of the things a purpose-built search engine can do, but Postgres can produce very good results with decent performance.
|
||||
|
||||
## A working Postgres FTS setup
|
||||
|
||||
Integrating Full Text Search into our imaginary movies table is as simple as executing the following:
|
||||
|
||||
```sql
|
||||
-- Add a generated column that contains the search document
|
||||
alter table movies
|
||||
add column fts_doc_en
|
||||
generated always as to_tsvector (
|
||||
'english', title || ' ' || original_title || ' ' || overview
|
||||
)
|
||||
stored;
|
||||
|
||||
-- Create a GIN index to make our searches faster
|
||||
create index movies_fts_doc_en_idx
|
||||
on movies
|
||||
using gin (fts_doc_en);
|
||||
```
|
||||
|
||||
Postgres supports even more features like [facilities for ranking search results](https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING) but for now we'll simply use `title`, `original_title`, and `overview` as they are.
|
||||
|
||||
```sql
|
||||
select
|
||||
* from movies
|
||||
where doc_en @@ websearch_to_tsquery('english', 'Avengers');
|
||||
```
|
||||
|
||||
## Comparable FTS engines
|
||||
|
||||
Now that we've got a grasp on what FTS is and how it's used in Postgres, let's discuss a few of the alternative solutions Postgres can be compared to.
|
||||
|
||||
As you might expect, this isn't an exhaustive list of FTS engines, there are many more that we're not covering here (If you've got another search engine you'd like to see featured, [file an issue](https://github.com/VADOSWARE/fts-benchmark/issues)).
|
||||
|
||||
### SQLite FTS
|
||||
|
||||
[SQLite](https://www.sqlite.org/) is arguably the most widely deployed database in the world. SQLite is a library that produces and manages single file (or in-memory) databases that run on phones, planes, laptops, kiosks, Raspberry Pis, and everything in between.
|
||||
|
||||
Much like Postgres, SQLite is an excellent F/OSS project that has adopted the ability to perform Full Text Search.
|
||||
|
||||
The [SQLite FTS documentation](https://www.sqlite.org/fts5.html) is extensive, and the FTS subsystems have undergone a few version upgrades -- [FTS 3/4](https://www.sqlite.org/fts3.html) and [SQLite FTS 5](https://www.sqlite.org/fts5.html) are both options.
|
||||
|
||||
For our experiments we'll use SQLite FTS5.
|
||||
|
||||
### TypeSense
|
||||
|
||||
[TypeSense](https://typesense.org/) is a relatively new project that focuses on _lightning fast_, typo-tolerant open source search. Typesense boasts a high performance C++ codebase, promising nearly instant search results.
|
||||
|
||||
TypeSense provides many demos with example datasets available for query, which you can try out:
|
||||
|
||||
- [32MM songs from MusicBrainz](https://songs-search.typesense.org/)
|
||||
- [28M books from OpenLibrary](https://books-search.typesense.org/)
|
||||
- [XKCD comic search by topic](https://xkcd-search.typesense.org/)
|
||||
|
||||
Typesense quite ambitiously brands itself as the "Algolia" and "Elasticsearch" alternative, offering to solve your search problems without requiring a PhD.
|
||||
|
||||
### MeiliSearch
|
||||
|
||||
[MeiliSearch](https://www.meilisearch.com/) is a new search engine written from the ground up in [Rust](https://rust-lang.org/), which also promises _lightning fast_ searches, flexibility, and typo tolerance.
|
||||
|
||||
MeiliSearch has [extensive documentation](https://docs.meilisearch.com/), boasts an easy to use yet extendable architecture, and is well supported by a large community and team.
|
||||
|
||||
### OpenSearch
|
||||
|
||||
AWS [OpenSearch](https://opensearch.org/) is AWS's answer to ElasticSearch's license changes. While opinions are varied, ElasticSearch built a great piece (formerly Apache 2.0 licensed) piece of software, and the AWS fork represents at least a _stale_ measure of ElasticSearch's capabilities.
|
||||
|
||||
OpenSearch is the _same_ as ElasticSearch (since the fork) but as it's license is much more permissive, it's the easy choice as a comparison to Postgres.
|
||||
|
||||
In production make sure to evaluate ElasticSearch versus OpenSearch (or other solutions) sufficiently for your use case.
|
||||
|
||||
## Testing our FTS solutions
|
||||
|
||||
Well, how do we test performance of these disparate complex systems on an infinitely redefinable, hard-to-solve problem? By sticking to the use cases.
|
||||
|
||||
Any search engine has two jobs:
|
||||
|
||||
- Ingest information (usually referred to as "documents")
|
||||
- Return documents that match user queries
|
||||
|
||||
Ingesting information _can_ be important, but "search engine performance" usually refers to the second step - taking queries and outputting results.
|
||||
|
||||
That is to say, the primary concern is assuming a cluster already has proper data loaded on to it, how fast can it deliver the relevant results for a user-submitted query?
|
||||
|
||||
We'll focus on the end-user-centric meaning of performance here (query speed).
|
||||
|
||||
### Getting a dataset
|
||||
|
||||
[Hugging Face](https://huggingface.co/) has a [32MB movie data set](https://huggingface.co/spaces/Kamand/Movie_Recommendation/blob/main/movies_metadata.csv). We're interested in the follow columns in this data set:
|
||||
|
||||
- `title`
|
||||
- `original_title`
|
||||
- `overview`
|
||||
|
||||
Postgres is capable of loading CSVs, but since we'll be using other search engines as well, let's convert to format that's much easier to use and process - [Newline Delimited JSON](https://dataprotocols.org/ndjson/).
|
||||
|
||||
After a few lines of code and one [`csv2ndjson.mjs` script](https://github.com/VADOSWARE/fts-benchmark/blob/main/src/util/csv2ndjson.mjs) later, we have a `movies.ndjson.json` full of JSON documents that we can easily ingest into any search engine (or other database for that matter!).
|
||||
|
||||
### Inserting the data
|
||||
|
||||
Each system ingests data slightly differently, but with a little scripting we can paper over the differences.
|
||||
|
||||
[The code](https://github.com/VADOSWARE/fts-benchmark) contains a scheme of "drivers" which are minimal ES6 modules.
|
||||
|
||||
Here's an example of the `ingest` function used for Postgres (with [`slonik`](https://github.com/gajus/slonik) as our DB driver):
|
||||
|
||||
```js
|
||||
// Ingesting search documents
|
||||
async function ingest({ document, pool }) {
|
||||
// Ignore rows without proper release dates
|
||||
if (document.release_date.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await pool.query(sql`
|
||||
insert into movies (
|
||||
id,
|
||||
title,
|
||||
original_title,
|
||||
overview,
|
||||
released_at
|
||||
)
|
||||
values
|
||||
(
|
||||
${document.id},
|
||||
${document.title},
|
||||
${document.original_title},
|
||||
${document.overview},
|
||||
${new Date(document.release_date).toISOString()}::timestamptz
|
||||
)
|
||||
on conflict (id) do update set
|
||||
title=${document.title},
|
||||
original_title=${document.original_title},
|
||||
overview=${document.overview},
|
||||
released_at=${new Date(document.release_date).toISOString()}::timestamptz
|
||||
;
|
||||
`)
|
||||
}
|
||||
```
|
||||
|
||||
The [repo](https://github.com/VADOSWARE/fts-benchmark/) contains the complete code for Postgres as well as the other search engines, written in much this same fashion.
|
||||
|
||||
### Picking a set of queries
|
||||
|
||||
This part is somewhat arbitrary - I've gotten a list of queries here that one might search for in terms of movies:
|
||||
|
||||
```
|
||||
"super hero"
|
||||
"superhero"
|
||||
"superman"
|
||||
"suprman"
|
||||
"love"
|
||||
"world war"
|
||||
"spy"
|
||||
"romance"
|
||||
"comedy"
|
||||
"awakening"
|
||||
```
|
||||
|
||||
Thanks to [boinboing.net's article from 2018](https://boingboing.net/2018/04/30/the-most-common-words-in-movie.html), there are some words that show up quite often in movie titles that we can search for.
|
||||
|
||||
The data set isn't very big, but doing all these searches in quick succession should be enough to get a realistic performance baseline.
|
||||
|
||||
### Running the queries
|
||||
|
||||
Similar to ingesting data, all the search engines take slightly different input for queries, so we'll change our simple string queries into whatever the relevant search engine expects to receive.
|
||||
|
||||
Here's what that script looks like for Postgres:
|
||||
|
||||
```js
|
||||
// Querying search phrases
|
||||
async function query({ phrase, pool }) {
|
||||
const results = await pool.query(sql`
|
||||
select id, title
|
||||
from movies
|
||||
where fts_doc_en @@ websearch_to_tsquery('english', ${phrase}::text)
|
||||
`)
|
||||
const ids = results.rows.map((r) => r.id)
|
||||
return ids
|
||||
}
|
||||
```
|
||||
|
||||
No surprises there, just like the Postgres FTS example from earlier.
|
||||
|
||||
## Automating our toil
|
||||
|
||||
Once we've combined our scripting magic with some `Makefile` incantations, running the queries produces output like this:
|
||||
|
||||
```
|
||||
TIMING=true FTS_ENGINE=pg make query
|
||||
[info] importing driver from [./pg.mjs]
|
||||
[info] finished initializing driver [pg]
|
||||
[info] processing lines in [/path/to/pg-fts-benchmark/search-phrases.ndjson.json]...
|
||||
[timing] phrase [super hero]: returned [34] results in 3.132471.ms
|
||||
[timing] phrase [superhero]: returned [86] results in 1.180798.ms
|
||||
[timing] phrase [superman]: returned [47] results in 0.912615.ms
|
||||
[timing] phrase [suprman]: returned [0] results in 0.781712.ms
|
||||
[timing] phrase [love]: returned [5417] results in 19.088668.ms
|
||||
[timing] phrase [world war]: returned [834] results in 2.902097.ms
|
||||
[timing] phrase [spy]: returned [349] results in 1.497892.ms
|
||||
[timing] phrase [romance]: returned [630] results in 1.91661.ms
|
||||
[timing] phrase [comedy]: returned [1213] results in 2.534538.ms
|
||||
[timing] phrase [awakening]: returned [210] results in 2.443748.ms
|
||||
[info] successfully processed [10] lines
|
||||
```
|
||||
|
||||
Right off the bat we can see that Postgres is plenty quick! Some results are quite obvious - as you might expect, "love" is _quite_ a common search result.
|
||||
|
||||
## Results
|
||||
|
||||
With the `ingest` and `query` machinery defined for the search engines, it's easy to get some results.
|
||||
|
||||
<PGChart />
|
||||
|
||||
## What does the data tell us?
|
||||
|
||||
There's a _lot_ of tuning left undone here, but a few points stand out right away:
|
||||
|
||||
- Even when consuming similar content, engines can produce different results, but generally ratios between queries on the same engine should be consistent.
|
||||
- Postgres FTS is quite close performance-wise to many other solutions, at least in their default configuration.
|
||||
- Only Typesense and MeiliSearch properly handled mis-spellings (the "suprman" query).
|
||||
- Typesense was relatively strict with matches compared to other engines.
|
||||
- OpenSearch was _very_ fast with ingest, but the default configuration doesn't index misspellings.
|
||||
- In-memory SQLite is by far the fastest, and PG isn't too far behind for this small data set.
|
||||
|
||||
## Feedback from the other products
|
||||
|
||||
Since this is a benchmark, we felt it was fair to give the other products an opportunity to give their feedback.
|
||||
|
||||
### Meilisearch
|
||||
|
||||
> I agree that using PG in simple use cases is an excellent way to be pragmatic.
|
||||
> Unfortunately, PG will never be able to offer you a search experience like the one you could have with Meilisearch. Meilisearch has perfect management of typos and searches by prefix, allowing a search at each keystroke. Enhanced relevancy with rules such as the number of words present, the importance of the attribute, the proximity of the terms searched in the document, and custom parameters. The ability to mix textual, geographic, and facet searches. Meilisearch automatically understands almost all languages with the possibility of having synonyms and stop words. Meilisearch has an incredible performance even on large volumes of data, and no backend proxy is needed, thanks to complete API Key management.
|
||||
|
||||
_Quentin de Quelen, Meilisearch CEO_
|
||||
|
||||
### Typesense
|
||||
|
||||
> Some more context around Typesense's behavior: how loose or exact matches are done are configurable in Typesense. For eg, you can configure the number of typos that are accounted for (num_typos), and when typo tolerance should kick-in, only if a configurable amount of results are not found (typo_tokens_treshold). For multi keyword searches, you can configure Typesense to expand the search by dropping some of the keywords, until at least a configurable number of search results are found (drop_tokens_threshold).
|
||||
> We've chosen specifically to keep the search tight because we received feedback from users that some results were confusing if we let the defaults be too loose.
|
||||
|
||||
_Jason Bosco, Typesense CEO_
|
||||
|
||||
## Wrap-up
|
||||
|
||||
While this certainly isn't "big data", it looks like Postgres has very much held it's own, even without too much wrangling/tuning. Postgres is clearly more than capable of delivering _good enough_ search for this very basic case.
|
||||
|
||||
Special purpose systems are expected to out-perform a more general system like Postgres, but clearly result quality and search speed provided Postgres is likely to be _good enough_ for many use cases, and is “complexity neutral” -- no new systems needed!
|
||||
|
||||
## More Postgres resources
|
||||
|
||||
- [Postgres WASM by Snaplet and Supabase](https://supabase.com/blog/postgres-wasm)
|
||||
- [Choosing a Postgres Primary Key](https://supabase.com/blog/choosing-a-postgres-primary-key)
|
||||
- [Implementing "seen by" functionality with Postgres](https://supabase.com/blog/seen-by-in-postgresql)
|
||||
- [Partial data dumps using Postgres Row Level Security](https://supabase.com/blog/partial-postgresql-data-dumps-with-rls)
|
||||
- [Postgres Views](https://supabase.com/blog/postgresql-views)
|
||||
- [Realtime Postgres RLS on Supabase](https://supabase.com/blog/realtime-row-level-security-in-postgresql)
|
||||
@@ -31,11 +31,6 @@ const Benchmark = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 rounded-r-md px-12 pb-12 lg:col-span-2 lg:px-0 lg:pb-0">
|
||||
<a href="https://supabasesubscribe.vercel.app/subscription/enterpriseBenchmarks">
|
||||
<Button>Get notified</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Tabs } from '@supabase/ui'
|
||||
import { useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'
|
||||
import * as data from '~/data/blog/PostgresFTSComparison'
|
||||
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const barColors = {
|
||||
pg: 'var(--colors-brand11)',
|
||||
meilisearch: 'var(--colors-blue11)',
|
||||
typesense: 'var(--colors-violet11)',
|
||||
'sqlite-disk': 'var(--colors-orange11)',
|
||||
'sqlite-mem': 'var(--colors-yellow11)',
|
||||
opensearch: 'var(--colors-tomato11)',
|
||||
}
|
||||
|
||||
const dimensions = ['pg', 'meilisearch', 'typesense', 'sqlite-disk', 'sqlite-mem', 'opensearch']
|
||||
|
||||
const renderColorfulLegendText = (value: string, entry: any) => {
|
||||
const { color } = entry
|
||||
|
||||
return (
|
||||
<span className="text-sm -mt-32" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomizedAxisTick = (props: any) => {
|
||||
const { x, y, stroke, payload } = props
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={-12}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill="var(--colors-scale10)"
|
||||
transform="rotate(-35)"
|
||||
className="text-xs"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
const Chart = () => {
|
||||
const [selection, setSelection] = useState('latency')
|
||||
return (
|
||||
<div className={'my-16 flex flex-col'}>
|
||||
<Tabs
|
||||
defaultActiveId={'latency'}
|
||||
type="underlined"
|
||||
size="medium"
|
||||
block
|
||||
onChange={(value: string) => setSelection(value)}
|
||||
>
|
||||
<Tabs.Panel id="latency" label="Latency"></Tabs.Panel>
|
||||
<Tabs.Panel id="results" label="Number of results"></Tabs.Panel>
|
||||
<Tabs.Panel id="avg_latency" label="Average latency"></Tabs.Panel>
|
||||
<Tabs.Panel id="raw_data" label="Raw data"></Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{selection !== 'raw_data' ? (
|
||||
<ResponsiveContainer height={720} minHeight={720} width="100%">
|
||||
{/* //
|
||||
@ts-ignore */}
|
||||
<BarChart data={data[selection]} layout="vertical">
|
||||
<XAxis
|
||||
axisLine={{ stroke: 'var(--colors-scale7)' }}
|
||||
tickLine={{ stroke: 'var(--colors-scale7)' }}
|
||||
type="number"
|
||||
tickMargin={8}
|
||||
style={{ fontSize: '14px', marginBottom: '32px', color: 'var(--colors-scale12)' }}
|
||||
tick={<CustomizedAxisTick />}
|
||||
label={{
|
||||
value:
|
||||
selection === 'latency'
|
||||
? 'Latency (ms)'
|
||||
: selection === 'avg_latency'
|
||||
? 'Avg latency per result (ms)'
|
||||
: 'Number of results',
|
||||
position: 'insideBottom',
|
||||
offset: -32,
|
||||
fill: 'var(--colors-scale12)',
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey={'query'}
|
||||
type="category"
|
||||
style={{ fontSize: '14px', marginBottom: '32px', color: 'var(--colors-scale10)' }}
|
||||
minTickGap={16}
|
||||
tickMargin={8}
|
||||
axisLine={{ stroke: 'var(--colors-scale7)' }}
|
||||
tickLine={{ stroke: 'var(--colors-scale7)' }}
|
||||
tick={<CustomizedAxisTick />}
|
||||
/>
|
||||
{dimensions.map((dimension: string, index: number) => {
|
||||
// @ts-ignore
|
||||
return <Bar dataKey={`${dimension}`} fill={barColors[dimension]} />
|
||||
})}
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={64}
|
||||
className={'mt-16'}
|
||||
iconSize={8}
|
||||
formatter={renderColorfulLegendText}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{`
|
||||
| engine | query | number of results | latency (ms) | avg result latency (ms) |
|
||||
|---------------|--------------|-------------------|--------------|-------------------------|
|
||||
| \`pg\` | "super hero" | \`34\` | \`3.68\` | \`0.108\` |
|
||||
| \`pg\` | "superhero" | \`86\` | \`1.59\` | \`0.0184\` |
|
||||
| \`pg\` | "superman" | \`47\` | \`1.24\` | \`0.0264\` |
|
||||
| \`pg\` | "suprman" | \`0\` | \`0.999\` | \`Infinity\` |
|
||||
| \`pg\` | "love" | \`5417\` | \`15.3\` | \`0.00282\` |
|
||||
| \`pg\` | "world war" | \`834\` | \`2.94\` | \`0.00352\` |
|
||||
| \`pg\` | "spy" | \`349\` | \`1.78\` | \`0.00510\` |
|
||||
| \`pg\` | "romance" | \`630\` | \`2.14\` | \`0.00339\` |
|
||||
| \`pg\` | "comedy" | \`1213\` | \`3.55\` | \`0.00292\` |
|
||||
| \`pg\` | "awakening" | \`210\` | \`1.93\` | \`0.00919\` |
|
||||
| \`meilisearch\` | "super hero" | \`56\` | \`4.82\` | \`0.0861\` |
|
||||
| \`meilisearch\` | "superhero" | \`14\` | \`4.39\` | \`0.313\` |
|
||||
| \`meilisearch\` | "superman" | \`41\` | \`3.45\` | \`0.0841\` |
|
||||
| \`meilisearch\` | "suprman" | \`29\` | \`2.69\` | \`0.0929\` |
|
||||
| \`meilisearch\` | "love" | \`812\` | \`12.4\` | \`0.0153\` |
|
||||
| \`meilisearch\` | "world war" | \`316\` | \`8.53\` | \`0.0270\` |
|
||||
| \`meilisearch\` | "spy" | \`47\` | \`3.57\` | \`0.0760\` |
|
||||
| \`meilisearch\` | "romance" | \`35\` | \`3.05\` | \`0.0871\` |
|
||||
| \`meilisearch\` | "comedy" | \`67\` | \`3.46\` | \`0.0516\` |
|
||||
| \`meilisearch\` | "awakening" | \`15\` | \`3.63\` | \`0.242\` |
|
||||
| \`typesense\` | "super hero" | \`8\` | \`3.99\` | \`0.499\` |
|
||||
| \`typesense\` | "superhero" | \`8\` | \`2.09\` | \`0.261\` |
|
||||
| \`typesense\` | "superman" | \`28\` | \`2.71\` | \`0.0968\` |
|
||||
| \`typesense\` | "suprman" | \`28\` | \`2.83\` | \`0.101\` |
|
||||
| \`typesense\` | "love" | \`745\` | \`30.6\` | \`0.0411\` |
|
||||
| \`typesense\` | "world war" | \`11\` | \`3.26\` | \`0.296\` |
|
||||
| \`typesense\` | "spy" | \`47\` | \`3.78\` | \`0.0804\` |
|
||||
| \`typesense\` | "romance" | \`34\` | \`2.68\` | \`0.0789\` |
|
||||
| \`typesense\` | "comedy" | \`55\` | \`3.14\` | \`0.0571\` |
|
||||
| \`typesense\` | "awakening" | \`14\` | \`2.07\` | \`0.148\` |
|
||||
| \`sqlite-disk\` | "super hero" | \`20\` | \`0.682\` | \`0.0341\` |
|
||||
| \`sqlite-disk\` | "superhero" | \`67\` | \`0.524\` | \`0.00782\` |
|
||||
| \`sqlite-disk\` | "superman" | \`47\` | \`0.355\` | \`0.00755\` |
|
||||
| \`sqlite-disk\` | "suprman" | \`0\` | \`0.0806\` | \`Infinity\` |
|
||||
| \`sqlite-disk\` | "love" | \`4691\` | \`27.7\` | \`0.00590\` |
|
||||
| \`sqlite-disk\` | "world war" | \`781\` | \`5.02\` | \`0.00642\` |
|
||||
| \`sqlite-disk\` | "spy" | \`241\` | \`0.916\` | \`0.00380\` |
|
||||
| \`sqlite-disk\` | "romance" | \`554\` | \`1.97\` | \`0.00355\` |
|
||||
| \`sqlite-disk\` | "comedy" | \`1220\` | \`4.25\` | \`0.00349\` |
|
||||
| \`sqlite-disk\` | "awakening" | \`63\` | \`0.303\` | \`0.00482\` |
|
||||
| \`sqlite-mem\` | "super hero" | \`20\` | \`0.392\` | \`0.0196\` |
|
||||
| \`sqlite-mem\` | "superhero" | \`67\` | \`0.237\` | \`0.00354\` |
|
||||
| \`sqlite-mem\` | "superman" | \`47\` | \`0.193\` | \`0.00410\` |
|
||||
| \`sqlite-mem\` | "suprman" | \`0\` | \`0.0568\` | \`Infinity\` |
|
||||
| \`sqlite-mem\` | "love" | \`4691\` | \`13.4\` | \`0.00286\` |
|
||||
| \`sqlite-mem\` | "world war" | \`781\` | \`2.93\` | \`0.00376\` |
|
||||
| \`sqlite-mem\` | "spy" | \`241\` | \`0.644\` | \`0.00267\` |
|
||||
| \`sqlite-mem\` | "romance" | \`554\` | \`1.43\` | \`0.00258\` |
|
||||
| \`sqlite-mem\` | "comedy" | \`1220\` | \`2.91\` | \`0.00239\` |
|
||||
| \`sqlite-mem\` | "awakening" | \`63\` | \`0.237\` | \`0.00377\` |
|
||||
| \`opensearch\` | "super hero" | \`431\` | \`112\` | \`0.260\` |
|
||||
| \`opensearch\` | "superhero" | \`45\` | \`12.6\` | \`0.281\` |
|
||||
| \`opensearch\` | "superman" | \`25\` | \`11.4\` | \`0.454\` |
|
||||
| \`opensearch\` | "suprman" | \`0\` | \`6.78\` | \`Infinity\` |
|
||||
| \`opensearch\` | "love" | \`3036\` | \`139\` | \`0.0457\` |
|
||||
| \`opensearch\` | "world war" | \`3805\` | \`149\` | \`0.0393\` |
|
||||
| \`opensearch\` | "spy" | \`168\` | \`14.1\` | \`0.0842\` |
|
||||
| \`opensearch\` | "romance" | \`383\` | \`23.9\` | \`0.0625\` |
|
||||
| \`opensearch\` | "comedy" | \`756\` | \`40.1\` | \`0.0531\` |
|
||||
| \`opensearch\` | "awakening" | \`32\` | \`9.84\` | \`0.307\` |`}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chart
|
||||
@@ -68,105 +68,116 @@ const EnterpriseContactForm = () => {
|
||||
return (
|
||||
<div>
|
||||
<Form initialValues={INITIAL_VALUES} validate={validate} onSubmit={handleFormSubmit}>
|
||||
{({ isSubmitting }: any) => (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Company Name *"
|
||||
id="companyName"
|
||||
name="companyName"
|
||||
layout="vertical"
|
||||
placeholder="Supa Inc."
|
||||
/>
|
||||
</div>
|
||||
{({ values, isSubmitting }: any) => {
|
||||
const selectedCountry = countries.find((country) => country.code === values['country'])
|
||||
const phonePlaceholder =
|
||||
selectedCountry !== undefined ? `+${selectedCountry.phone_code}` : ''
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1">
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Your First Name *"
|
||||
id="contactFirstName"
|
||||
name="contactFirstName"
|
||||
label="Company Name *"
|
||||
id="companyName"
|
||||
name="companyName"
|
||||
layout="vertical"
|
||||
placeholder="Jane"
|
||||
placeholder="Supa Inc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Your First Name *"
|
||||
id="contactFirstName"
|
||||
name="contactFirstName"
|
||||
layout="vertical"
|
||||
placeholder="Jane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Your Last Name *"
|
||||
id="contactLastName"
|
||||
name="contactLastName"
|
||||
layout="vertical"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Your Last Name *"
|
||||
id="contactLastName"
|
||||
name="contactLastName"
|
||||
label="Your Business Email *"
|
||||
id="contactEmail"
|
||||
name="contactEmail"
|
||||
layout="vertical"
|
||||
placeholder="Doe"
|
||||
placeholder="janedoe@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Your Business Email *"
|
||||
id="contactEmail"
|
||||
name="contactEmail"
|
||||
layout="vertical"
|
||||
placeholder="janedoe@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select label="Company Size" id="companySize" name="companySize" layout="vertical">
|
||||
<Select.Option value="">{'--'}</Select.Option>
|
||||
<Select.Option value="1-10">1-10 Employees</Select.Option>
|
||||
<Select.Option value="11-50">11-50 Employees</Select.Option>
|
||||
<Select.Option value="51-200">51-200 Employees</Select.Option>
|
||||
<Select.Option value="201-500">201-500 Employees</Select.Option>
|
||||
<Select.Option value="501-1000">501-1000 Employees</Select.Option>
|
||||
<Select.Option value="1001-5000">1001-5000 Employees</Select.Option>
|
||||
<Select.Option value="5001+">5001+ Employees</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select label="Company Size" id="companySize" name="companySize" layout="vertical">
|
||||
<Select.Option value="">{'--'}</Select.Option>
|
||||
<Select.Option value="1-10">1-10 Employees</Select.Option>
|
||||
<Select.Option value="11-50">11-50 Employees</Select.Option>
|
||||
<Select.Option value="51-200">51-200 Employees</Select.Option>
|
||||
<Select.Option value="201-500">201-500 Employees</Select.Option>
|
||||
<Select.Option value="501-1000">501-1000 Employees</Select.Option>
|
||||
<Select.Option value="1001-5000">1001-5000 Employees</Select.Option>
|
||||
<Select.Option value="5001+">5001+ Employees</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Your Phone Number"
|
||||
id="contactPhone"
|
||||
name="contactPhone"
|
||||
layout="vertical"
|
||||
placeholder={phonePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Your Phone Number"
|
||||
id="contactPhone"
|
||||
name="contactPhone"
|
||||
layout="vertical"
|
||||
placeholder="+65 1234 1234"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
label="Country / Main Timezone"
|
||||
id="country"
|
||||
name="country"
|
||||
layout="vertical"
|
||||
>
|
||||
{countries.map(({ code, name }) => (
|
||||
<Select.Option key={code} value={code}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select label="Country / Main Timezone" id="country" name="country" layout="vertical">
|
||||
{countries.map(({ code, name }) => (
|
||||
<Select.Option key={code} value={code}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="">
|
||||
<Input.TextArea
|
||||
id="details"
|
||||
name="details"
|
||||
label="Additional Details"
|
||||
placeholder="Tell us about your projects, clients, and technology..."
|
||||
rows={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Input.TextArea
|
||||
id="details"
|
||||
name="details"
|
||||
label="Additional Details"
|
||||
placeholder="Tell us about your projects, clients, and technology..."
|
||||
rows={10}
|
||||
/>
|
||||
<div className="col-span-2 flex w-full flex-row-reverse pt-4">
|
||||
<Button
|
||||
size="xlarge"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
htmlType="submit"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex w-full flex-row-reverse pt-4">
|
||||
<Button
|
||||
size="xlarge"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
htmlType="submit"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
|
||||
{formSubmitted && (
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Button, Form, Input, Select } from '@supabase/ui'
|
||||
import { useState } from 'react'
|
||||
import countries from '~/data/Countries.json'
|
||||
import supabase from '~/lib/supabase'
|
||||
|
||||
const INITIAL_VALUES = {
|
||||
companyName: '',
|
||||
contactFirstName: '',
|
||||
contactLastName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
companySize: '',
|
||||
details: '',
|
||||
country: 'US',
|
||||
}
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
|
||||
if (!values.companyName) {
|
||||
errors.companyName = 'Required'
|
||||
}
|
||||
|
||||
if (!values.contactFirstName) {
|
||||
errors.contactFirstName = 'Required'
|
||||
}
|
||||
|
||||
if (!values.contactLastName) {
|
||||
errors.contactLastName = 'Required'
|
||||
}
|
||||
|
||||
if (!values.contactEmail) {
|
||||
errors.contactEmail = 'Required'
|
||||
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.contactEmail)) {
|
||||
errors.contactEmail = 'Invalid email address'
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const SOC2RequestForm = () => {
|
||||
const [error, setError] = useState<string>()
|
||||
const [formSubmitted, setFormSubmitted] = useState<boolean>(false)
|
||||
|
||||
const handleFormSubmit = async (values: typeof INITIAL_VALUES, { resetForm }: any) => {
|
||||
const { error } = await supabase.from('soc2_requests').insert([
|
||||
{
|
||||
company_name: values.companyName,
|
||||
contact_name: `${values.contactFirstName} ${values.contactLastName}`,
|
||||
contact_first_name: values.contactFirstName,
|
||||
contact_last_name: values.contactLastName,
|
||||
contact_email: values.contactEmail,
|
||||
contact_phone: values.contactPhone,
|
||||
company_size: values.companySize,
|
||||
details: values.details,
|
||||
country: values.country,
|
||||
},
|
||||
])
|
||||
|
||||
if (!error) {
|
||||
resetForm()
|
||||
setFormSubmitted(true)
|
||||
} else {
|
||||
setError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{formSubmitted ? (
|
||||
<h3 className="h3 text-brand-900 mt-12 text-center">
|
||||
Thank you for your submission! A member from the Supabase team will reach out to you
|
||||
shortly!
|
||||
</h3>
|
||||
) : (
|
||||
<>
|
||||
<Form initialValues={INITIAL_VALUES} validate={validate} onSubmit={handleFormSubmit}>
|
||||
{({ values, isSubmitting }: any) => {
|
||||
const selectedCountry = countries.find(
|
||||
(country) => country.code === values['country']
|
||||
)
|
||||
const phonePlaceholder =
|
||||
selectedCountry !== undefined ? `+${selectedCountry.phone_code}` : ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div>
|
||||
<Input
|
||||
label="Company Name *"
|
||||
id="companyName"
|
||||
name="companyName"
|
||||
layout="vertical"
|
||||
placeholder="Supa Inc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Your First Name *"
|
||||
id="contactFirstName"
|
||||
name="contactFirstName"
|
||||
layout="vertical"
|
||||
placeholder="Jane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Your Last Name *"
|
||||
id="contactLastName"
|
||||
name="contactLastName"
|
||||
layout="vertical"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Your Business Email *"
|
||||
id="contactEmail"
|
||||
name="contactEmail"
|
||||
layout="vertical"
|
||||
placeholder="janedoe@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label="Company Size"
|
||||
id="companySize"
|
||||
name="companySize"
|
||||
layout="vertical"
|
||||
>
|
||||
<Select.Option value="">{'--'}</Select.Option>
|
||||
<Select.Option value="1-10">1-10 Employees</Select.Option>
|
||||
<Select.Option value="11-50">11-50 Employees</Select.Option>
|
||||
<Select.Option value="51-200">51-200 Employees</Select.Option>
|
||||
<Select.Option value="201-500">201-500 Employees</Select.Option>
|
||||
<Select.Option value="501-1000">501-1000 Employees</Select.Option>
|
||||
<Select.Option value="1001-5000">1001-5000 Employees</Select.Option>
|
||||
<Select.Option value="5001+">5001+ Employees</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
label="Country / Main Timezone"
|
||||
id="country"
|
||||
name="country"
|
||||
layout="vertical"
|
||||
>
|
||||
{countries.map(({ code, name }) => (
|
||||
<Select.Option key={code} value={code}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Your Phone Number"
|
||||
id="contactPhone"
|
||||
name="contactPhone"
|
||||
layout="vertical"
|
||||
placeholder={phonePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Input.TextArea
|
||||
id="details"
|
||||
name="details"
|
||||
label="Additional Details"
|
||||
placeholder="Tell us about your projects, clients, and technology..."
|
||||
rows={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex w-full flex-row-reverse pt-4">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="text-white"
|
||||
htmlType="submit"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Submit request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
{error && <p className="text-sm">Error: {error}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SOC2RequestForm
|
||||
+500
-250
File diff suppressed because it is too large.
Load diff
@@ -62,6 +62,10 @@
|
||||
{
|
||||
"text": "DPA",
|
||||
"url": "/legal/dpa"
|
||||
},
|
||||
{
|
||||
"text": "SOC2",
|
||||
"url": "/legal/soc2"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
const latency = [
|
||||
{
|
||||
query: 'awakening',
|
||||
meilisearch: 3.63,
|
||||
opensearch: 9.84,
|
||||
pg: 1.93,
|
||||
'sqlite-disk': 0.3,
|
||||
'sqlite-mem': 0.24,
|
||||
typesense: 2.07,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'comedy',
|
||||
meilisearch: 3.46,
|
||||
opensearch: 40.1,
|
||||
pg: 3.55,
|
||||
'sqlite-disk': 4.25,
|
||||
'sqlite-mem': 2.91,
|
||||
typesense: 3.14,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'love',
|
||||
meilisearch: 12.4,
|
||||
opensearch: 139.0,
|
||||
pg: 15.3,
|
||||
'sqlite-disk': 27.7,
|
||||
'sqlite-mem': 13.4,
|
||||
typesense: 30.6,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'romance',
|
||||
meilisearch: 3.05,
|
||||
opensearch: 23.9,
|
||||
pg: 2.14,
|
||||
'sqlite-disk': 1.97,
|
||||
'sqlite-mem': 1.43,
|
||||
typesense: 2.68,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'spy',
|
||||
meilisearch: 3.57,
|
||||
opensearch: 14.1,
|
||||
pg: 1.78,
|
||||
'sqlite-disk': 0.92,
|
||||
'sqlite-mem': 0.64,
|
||||
typesense: 3.78,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'superhero',
|
||||
meilisearch: 4.82,
|
||||
opensearch: 112.0,
|
||||
pg: 3.68,
|
||||
'sqlite-disk': 0.68,
|
||||
'sqlite-mem': 0.39,
|
||||
typesense: 3.99,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'superhero',
|
||||
meilisearch: 4.39,
|
||||
opensearch: 12.6,
|
||||
pg: 1.59,
|
||||
'sqlite-disk': 0.52,
|
||||
'sqlite-mem': 0.24,
|
||||
typesense: 2.09,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'superman',
|
||||
meilisearch: 3.45,
|
||||
opensearch: 11.4,
|
||||
pg: 1.24,
|
||||
'sqlite-disk': 0.36,
|
||||
'sqlite-mem': 0.19,
|
||||
typesense: 2.71,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'suprman',
|
||||
meilisearch: 2.69,
|
||||
opensearch: 6.78,
|
||||
pg: 1.0,
|
||||
'sqlite-disk': 0.08,
|
||||
'sqlite-mem': 0.06,
|
||||
typesense: 2.83,
|
||||
},
|
||||
|
||||
{
|
||||
query: 'worldwar',
|
||||
meilisearch: 8.53,
|
||||
opensearch: 149.0,
|
||||
pg: 2.94,
|
||||
'sqlite-disk': 5.02,
|
||||
'sqlite-mem': 2.93,
|
||||
typesense: 3.26,
|
||||
},
|
||||
]
|
||||
|
||||
const results = [
|
||||
{
|
||||
query: 'awakening',
|
||||
meilisearch: 15,
|
||||
opensearch: 32,
|
||||
pg: 210,
|
||||
'sqlite-disk': 63,
|
||||
'sqlite-mem': 63,
|
||||
typesense: 14,
|
||||
},
|
||||
{
|
||||
query: 'comedy',
|
||||
meilisearch: 67,
|
||||
opensearch: 756,
|
||||
pg: 1213,
|
||||
'sqlite-disk': 1220,
|
||||
'sqlite-mem': 1220,
|
||||
typesense: 55,
|
||||
},
|
||||
{
|
||||
query: 'love',
|
||||
meilisearch: 812,
|
||||
opensearch: 3036,
|
||||
pg: 5417,
|
||||
'sqlite-disk': 4691,
|
||||
'sqlite-mem': 4691,
|
||||
typesense: 745,
|
||||
},
|
||||
{
|
||||
query: 'romance',
|
||||
meilisearch: 35,
|
||||
opensearch: 383,
|
||||
pg: 630,
|
||||
'sqlite-disk': 554,
|
||||
'sqlite-mem': 554,
|
||||
typesense: 34,
|
||||
},
|
||||
{
|
||||
query: 'spy',
|
||||
meilisearch: 47,
|
||||
opensearch: 168,
|
||||
pg: 349,
|
||||
'sqlite-disk': 241,
|
||||
'sqlite-mem': 241,
|
||||
typesense: 47,
|
||||
},
|
||||
{
|
||||
query: 'superhero',
|
||||
meilisearch: 56,
|
||||
opensearch: 431,
|
||||
pg: 34,
|
||||
'sqlite-disk': 20,
|
||||
'sqlite-mem': 20,
|
||||
typesense: 8,
|
||||
},
|
||||
{
|
||||
query: 'superhero',
|
||||
meilisearch: 14,
|
||||
opensearch: 45,
|
||||
pg: 86,
|
||||
'sqlite-disk': 67,
|
||||
'sqlite-mem': 67,
|
||||
typesense: 8,
|
||||
},
|
||||
{
|
||||
query: 'superman',
|
||||
meilisearch: 41,
|
||||
opensearch: 25,
|
||||
pg: 47,
|
||||
'sqlite-disk': 47,
|
||||
'sqlite-mem': 47,
|
||||
typesense: 28,
|
||||
},
|
||||
{
|
||||
query: 'suprman',
|
||||
meilisearch: 29,
|
||||
opensearch: 0,
|
||||
pg: 0,
|
||||
'sqlite-disk': 0,
|
||||
'sqlite-mem': 0,
|
||||
typesense: 28,
|
||||
},
|
||||
{
|
||||
query: 'worldwar',
|
||||
meilisearch: 316,
|
||||
opensearch: 3805,
|
||||
pg: 834,
|
||||
'sqlite-disk': 781,
|
||||
'sqlite-mem': 781,
|
||||
typesense: 11,
|
||||
},
|
||||
]
|
||||
|
||||
const avg_latency = [
|
||||
{
|
||||
query: 'awakening',
|
||||
meilisearch: 0.24,
|
||||
opensearch: 0.31,
|
||||
pg: 0.01,
|
||||
'sqlite-disk': 0.0,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.15,
|
||||
},
|
||||
{
|
||||
query: 'comedy',
|
||||
meilisearch: 0.05,
|
||||
opensearch: 0.05,
|
||||
pg: 0.0,
|
||||
'sqlite-disk': 0.0,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.06,
|
||||
},
|
||||
{
|
||||
query: 'love',
|
||||
meilisearch: 0.02,
|
||||
opensearch: 0.05,
|
||||
pg: 0.0,
|
||||
'sqlite-disk': 0.01,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.04,
|
||||
},
|
||||
{
|
||||
query: 'romance',
|
||||
meilisearch: 0.09,
|
||||
opensearch: 0.06,
|
||||
pg: 0.0,
|
||||
'sqlite-disk': 0.0,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.08,
|
||||
},
|
||||
{
|
||||
query: 'spy',
|
||||
meilisearch: 0.08,
|
||||
opensearch: 0.08,
|
||||
pg: 0.01,
|
||||
'sqlite-disk': 0.0,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.08,
|
||||
},
|
||||
{
|
||||
query: 'superhero',
|
||||
meilisearch: 0.09,
|
||||
opensearch: 0.26,
|
||||
pg: 0.11,
|
||||
'sqlite-disk': 0.03,
|
||||
'sqlite-mem': 0.02,
|
||||
typesense: 0.5,
|
||||
},
|
||||
{
|
||||
query: 'superhero',
|
||||
meilisearch: 0.31,
|
||||
opensearch: 0.28,
|
||||
pg: 0.02,
|
||||
'sqlite-disk': 0.01,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.26,
|
||||
},
|
||||
{
|
||||
query: 'superman',
|
||||
meilisearch: 0.08,
|
||||
opensearch: 0.45,
|
||||
pg: 0.03,
|
||||
'sqlite-disk': 0.01,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.1,
|
||||
},
|
||||
{
|
||||
query: 'suprman',
|
||||
meilisearch: 0.09,
|
||||
opensearch: undefined,
|
||||
pg: undefined,
|
||||
'sqlite-disk': undefined,
|
||||
'sqlite-mem': undefined,
|
||||
typesense: 0.1,
|
||||
},
|
||||
{
|
||||
query: 'worldwar',
|
||||
meilisearch: 0.03,
|
||||
opensearch: 0.04,
|
||||
pg: 0.0,
|
||||
'sqlite-disk': 0.01,
|
||||
'sqlite-mem': 0.0,
|
||||
typesense: 0.3,
|
||||
},
|
||||
]
|
||||
|
||||
export { latency, results, avg_latency }
|
||||
@@ -3,6 +3,7 @@ import Avatar from '~/components/Avatar'
|
||||
import CodeBlock from '~/components/CodeBlock/CodeBlock'
|
||||
import ImageGrid from '~/components/ImageGrid'
|
||||
import Quote from '~/components/Quote'
|
||||
import Chart from '~/components/Charts/PGCharts'
|
||||
|
||||
// import all components used in blog articles here
|
||||
// to do: move this into a helper/utils, it is used elsewhere
|
||||
@@ -14,6 +15,9 @@ export default function mdxComponents(type?: 'blog' | 'lp' | undefined) {
|
||||
CodeBlock,
|
||||
Quote,
|
||||
Avatar,
|
||||
PGChart: (props: any) => {
|
||||
return <Chart {...props} />
|
||||
},
|
||||
pre: (props: any) => {
|
||||
if (props.className !== ignoreClass) {
|
||||
return <CodeBlock {...props.children.props} />
|
||||
|
||||
@@ -1415,4 +1415,50 @@ module.exports = [
|
||||
// source: '/docs/reference/javascript/auth-api-updateuserbyid',
|
||||
// destination: '/docs/reference/javascript/auth-admin-updateuserbyid',
|
||||
// },
|
||||
|
||||
// v1 to v2 redirects
|
||||
|
||||
// signIn method is now split into signInWithPassword ,signInWithPasswordless ,signInWithOAuth
|
||||
// send traffic to v1 docs instead
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-signin',
|
||||
destination: '/docs/reference/javascript/v1/auth-signin',
|
||||
},
|
||||
// auth.session renamed to auth.getSession
|
||||
// send to new v2 docs version
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-session',
|
||||
destination: '/docs/reference/javascript/auth-getsession',
|
||||
},
|
||||
// Removed sendMobileOTP in favor of signInWithOtp
|
||||
// send traffic to new v2 docs instead
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-sendmobileotp',
|
||||
destination: '/docs/reference/javascript/auth-signinwithotp',
|
||||
},
|
||||
// Replaced update with updateUser
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-update',
|
||||
destination: '/docs/reference/javascript/auth-updateuser',
|
||||
},
|
||||
// realtime methods been replaced with new names
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/removesubscription',
|
||||
destination: '/docs/reference/javascript/v1/removesubscription',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/removeallsubscriptions',
|
||||
destination: '/docs/reference/javascript/v1/removeallsubscriptions',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/getsubscriptions',
|
||||
destination: '/docs/reference/javascript/v1/getsubscriptions',
|
||||
},
|
||||
]
|
||||
@@ -40,6 +40,7 @@
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-tooltip": "^4.2.17",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"recharts": "^2.1.15",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"remark": "^13.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
import { Button } from '@supabase/ui'
|
||||
import CTABanner from 'components/CTABanner/index'
|
||||
import * as Yup from 'yup'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, IconDownload, Input } from '@supabase/ui'
|
||||
|
||||
import supabase from '~/lib/supabase'
|
||||
import CTABanner from 'components/CTABanner/index'
|
||||
import Layout from '~/components/Layouts/Default'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
|
||||
const DPA = () => {
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [error, setError] = useState<string>()
|
||||
const [formSubmitted, setFormSubmitted] = useState<boolean>(false)
|
||||
|
||||
const INITIAL_VALUES = { email: '' }
|
||||
|
||||
const FormSchema = Yup.object().shape({
|
||||
email: Yup.string().email('Invalid email').required('Required'),
|
||||
})
|
||||
|
||||
const handleFormSubmit = async (values: typeof INITIAL_VALUES, { resetForm }: any) => {
|
||||
try {
|
||||
setError(undefined)
|
||||
const { error } = await supabase
|
||||
.from('dpa_downloads')
|
||||
.insert([{ contact_email: values.email, document: 'dpa' }])
|
||||
|
||||
if (error) throw error
|
||||
|
||||
resetForm()
|
||||
setFormSubmitted(true)
|
||||
window.open('https://supabase.com/downloads/docs/Supabase+DPA+220503.pdf', '_blank')
|
||||
} catch (error: any) {
|
||||
setError(error.message)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
@@ -19,14 +49,52 @@ const DPA = () => {
|
||||
("DPA").
|
||||
</p>
|
||||
|
||||
<p>You can download the latest DPA document through our security portal.</p>
|
||||
<div>
|
||||
<Link href="https://security.supabase.com">
|
||||
<Button size="medium" type="default">
|
||||
Security portal
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p>
|
||||
You can download the latest DPA document through our{' '}
|
||||
<Link href="https://security.supabase.com/">security portal</Link>, or by
|
||||
submitting your email here.
|
||||
</p>
|
||||
|
||||
{formSubmitted ? (
|
||||
<p className="text-brand-900">
|
||||
Thank you for your submission! A new tab should have opened with the DPA
|
||||
document
|
||||
</p>
|
||||
) : (
|
||||
<Form
|
||||
initialValues={INITIAL_VALUES}
|
||||
validationSchema={FormSchema}
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
{({ isSubmitting }: any) => (
|
||||
<>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
descriptionText="We only keep a record of your email so we can update you when the document has been updated."
|
||||
placeholder="Your email address"
|
||||
error={error}
|
||||
actions={
|
||||
<Button
|
||||
htmlType="submit"
|
||||
type="default"
|
||||
iconRight={<IconDownload />}
|
||||
className="mr-1"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Download DPA document
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{error && <p>{error}</p>}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,5 +104,4 @@ const DPA = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DPA
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from 'next/link'
|
||||
import CTABanner from 'components/CTABanner/index'
|
||||
import Layout from '~/components/Layouts/Default'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import SOC2RequestForm from '~/components/SOC2RequestForm'
|
||||
|
||||
const SOC2 = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<SectionContainer>
|
||||
<div className="mx-auto grid max-w-2xl grid-cols-12 rounded-lg">
|
||||
<div className="col-span-12 flex items-center lg:col-span-12">
|
||||
<div className="prose flex flex-col space-y-8 p-16">
|
||||
<h1 className="text-center text-5xl">SOC2</h1>
|
||||
<p>
|
||||
As a database company, being SOC2 compliant is important when handling sensitive
|
||||
customer data. Supabase is currently SOC2 Type 1 compliant and we're also working on
|
||||
getting certified for SOC2 Type 2 and HIPAA next.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can request for our latest SOC 2 document either through our{' '}
|
||||
<Link href="https://security.supabase.com/">security portal</Link>, or by submitting
|
||||
your request here.
|
||||
</p>
|
||||
|
||||
<SOC2RequestForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
<CTABanner />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
export default SOC2
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 748 KiB |
+15
-1
@@ -5,9 +5,23 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Sun, 02 Oct 2022 16:04:00 +0000</lastBuildDate>
|
||||
<lastBuildDate>Tue, 11 Oct 2022 16:04:00 +0000</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/postgres-full-text-search-vs-the-rest</guid>
|
||||
<title>Postgres FTS vs the rest</title>
|
||||
<link>https://supabase.com/blog/postgres-full-text-search-vs-the-rest</link>
|
||||
<description>Comparing one of the most popular Postgres features against alternatives</description>
|
||||
<pubDate>Tue, 11 Oct 2022 16:04:00 +0000</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/supabase-beta-update-september-2022</guid>
|
||||
<title>Supabase Beta September 2022</title>
|
||||
<link>https://supabase.com/blog/supabase-beta-update-september-2022</link>
|
||||
<description>We were too focused on clearing out the backlog so we didn't ship anything new last month... or did we?!</description>
|
||||
<pubDate>Tue, 04 Oct 2022 16:04:00 +0000</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/postgres-wasm</guid>
|
||||
<title>Postgres WASM by Snaplet and Supabase</title>
|
||||
<link>https://supabase.com/blog/postgres-wasm</link>
|
||||
|
||||
@@ -420,3 +420,9 @@ launch week
|
||||
--rotate: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
/* recharts */
|
||||
|
||||
.recharts-surface {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ const FUNCTION_SECRET = Deno.env.get('FUNCTION_SECRET')
|
||||
const HUBSPOT_PORTAL = Deno.env.get('HUBSPOT_PORTAL')
|
||||
const PARTNER_FORM_ID = Deno.env.get('PARTNER_FORM_ID')
|
||||
const ENTERPRISE_FORM_ID = Deno.env.get('ENTERPRISE_FORM_ID')
|
||||
const DPA_FORM_ID = Deno.env.get('DPA_FORM_ID')
|
||||
const SOC2_FORM_ID = Deno.env.get('SOC2_FORM_ID')
|
||||
|
||||
serve(async (req) => {
|
||||
const requestSecret = req.headers.get('x-function-secret')
|
||||
@@ -27,25 +29,32 @@ serve(async (req) => {
|
||||
const website = input.record.website
|
||||
const partner_gallery_type = input.record.type
|
||||
|
||||
let formData: object = {
|
||||
supabase_table_id,
|
||||
country,
|
||||
form_note,
|
||||
company,
|
||||
company_size,
|
||||
email,
|
||||
phone,
|
||||
lastName,
|
||||
firstName,
|
||||
}
|
||||
let formData: object = { email }
|
||||
|
||||
if (input.table === 'partner_contacts') {
|
||||
formData = {
|
||||
...formData,
|
||||
jobtitle,
|
||||
website,
|
||||
partner_gallery_type,
|
||||
}
|
||||
switch (input.table) {
|
||||
case 'partner_contacts':
|
||||
formData = { ...formData, jobtitle, website, partner_gallery_type }
|
||||
break
|
||||
case 'soc2_requests':
|
||||
case 'enterprise_contacts':
|
||||
formData = {
|
||||
...formData,
|
||||
supabase_table_id,
|
||||
country,
|
||||
form_note,
|
||||
company,
|
||||
company_size,
|
||||
email,
|
||||
phone,
|
||||
lastName,
|
||||
firstName,
|
||||
}
|
||||
break
|
||||
case 'dpa_downloads':
|
||||
formData = { ...formData }
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
const fields: { objectTypeId: '0-1'; name: string; value: string }[] = []
|
||||
@@ -53,7 +62,14 @@ serve(async (req) => {
|
||||
fields.push({ objectTypeId: '0-1', name: key, value: value || 'NOT_PROVIDED' })
|
||||
}
|
||||
|
||||
const formId = input.table === 'partner_contacts' ? PARTNER_FORM_ID : ENTERPRISE_FORM_ID
|
||||
const formId =
|
||||
input.table === 'partner_contacts'
|
||||
? PARTNER_FORM_ID
|
||||
: input.table === 'dpa_downloads'
|
||||
? DPA_FORM_ID
|
||||
: input.table === 'soc2_requests'
|
||||
? SOC2_FORM_ID
|
||||
: ENTERPRISE_FORM_ID
|
||||
|
||||
// Using HubSpot's submit data to a form API
|
||||
// https://legacydocs.hubspot.com/docs/methods/forms/submit_form
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
# https://github.com/supabase/cli/issues/14
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
volumes:
|
||||
- ./volumes/api:/var/lib/kong
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@1"></script>
|
||||
<script src="./index.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="./index.css">
|
||||
</head>
|
||||
|
||||
Generated
+74
@@ -144,6 +144,7 @@
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-tooltip": "^4.2.17",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"recharts": "^2.1.15",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"remark": "^13.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
@@ -401,6 +402,47 @@
|
||||
"node": ">= 8.3"
|
||||
}
|
||||
},
|
||||
"apps/www/node_modules/recharts": {
|
||||
"version": "2.1.15",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.15.tgz",
|
||||
"integrity": "sha512-16VMAvrsdqkEe7sT9uyJ1F+OZzrC6+5eW+pVSU04SVO6O5Nr5lY78h/uKMjsZJTN0nfOfgV7YfHZcivtqgPU9g==",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "^2.0.0",
|
||||
"@types/d3-scale": "^3.0.0",
|
||||
"@types/d3-shape": "^2.0.0",
|
||||
"classnames": "^2.2.5",
|
||||
"d3-interpolate": "^2.0.0",
|
||||
"d3-scale": "^3.0.0",
|
||||
"d3-shape": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.19",
|
||||
"react-is": "^16.10.2",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-smooth": "^2.0.1",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"reduce-css-calc": "^2.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prop-types": "^15.6.0",
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"apps/www/node_modules/recharts/node_modules/react-resize-detector": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
|
||||
"integrity": "sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"apps/www/node_modules/remark": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark/-/remark-13.0.0.tgz",
|
||||
@@ -63831,6 +63873,7 @@
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-tooltip": "^4.2.17",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"recharts": "^2.1.15",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"remark": "^13.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
@@ -64017,6 +64060,37 @@
|
||||
"react-is": "^16.12.0"
|
||||
}
|
||||
},
|
||||
"recharts": {
|
||||
"version": "2.1.15",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.15.tgz",
|
||||
"integrity": "sha512-16VMAvrsdqkEe7sT9uyJ1F+OZzrC6+5eW+pVSU04SVO6O5Nr5lY78h/uKMjsZJTN0nfOfgV7YfHZcivtqgPU9g==",
|
||||
"requires": {
|
||||
"@types/d3-interpolate": "^2.0.0",
|
||||
"@types/d3-scale": "^3.0.0",
|
||||
"@types/d3-shape": "^2.0.0",
|
||||
"classnames": "^2.2.5",
|
||||
"d3-interpolate": "^2.0.0",
|
||||
"d3-scale": "^3.0.0",
|
||||
"d3-shape": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.19",
|
||||
"react-is": "^16.10.2",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-smooth": "^2.0.1",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"reduce-css-calc": "^2.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-resize-detector": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
|
||||
"integrity": "sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"remark": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark/-/remark-13.0.0.tgz",
|
||||
|
||||
@@ -33,10 +33,10 @@ const SchemaTable: FC<Props> = ({ filterString, schema, editTrigger, deleteTrigg
|
||||
<Table.th key="function" className="hidden xl:table-cell">
|
||||
Function
|
||||
</Table.th>
|
||||
<Table.th key="rows" className="hidden xl:table-cell">
|
||||
<Table.th key="rows" className="hidden xl:table-cell xl:w-1/3">
|
||||
Events
|
||||
</Table.th>
|
||||
<Table.th key="buttons" className="w-1/6"></Table.th>
|
||||
<Table.th key="buttons" className="w-1/12"></Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
|
||||
@@ -49,9 +49,9 @@ const TriggerList: FC<Props> = ({ filterString, schema, editTrigger, deleteTrigg
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="hidden xl:table-cell">
|
||||
<div className="space-x-2">
|
||||
<div className="flex space-x-2">
|
||||
{x.events.map((event: string) => (
|
||||
<Badge key={event}>{event}</Badge>
|
||||
<Badge key={event}>{`${x.activation} ${event}`}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Table.td>
|
||||
|
||||
@@ -163,6 +163,7 @@ limit 100
|
||||
},
|
||||
{
|
||||
label: 'Error Count by User',
|
||||
description: 'Count of errors by users',
|
||||
mode: 'custom',
|
||||
searchString: `select
|
||||
count(t.timestamp) as count,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FC, useEffect } from 'react'
|
||||
|
||||
@@ -14,59 +14,61 @@ export const LogsTemplatesPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{TEMPLATES.filter((template) => template.mode === 'custom').map((template, i) => {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
return (
|
||||
<CardButton
|
||||
key={i}
|
||||
title={template.label}
|
||||
icon={
|
||||
<div
|
||||
className="duration-400 flex h-6 w-6 items-center justify-center rounded
|
||||
{TEMPLATES.sort((a, b) => a.label!.localeCompare(b.label!))
|
||||
.filter((template) => template.mode === 'custom')
|
||||
.map((template, i) => {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
return (
|
||||
<CardButton
|
||||
key={i}
|
||||
title={template.label}
|
||||
icon={
|
||||
<div
|
||||
className="duration-400 flex h-6 w-6 items-center justify-center rounded
|
||||
bg-scale-1200
|
||||
text-scale-100
|
||||
transition-colors
|
||||
group-hover:bg-brand-900
|
||||
group-hover:text-brand-1200
|
||||
"
|
||||
>
|
||||
<div className="scale-100 group-hover:scale-110">
|
||||
<IconCode size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
containerHeightClassName="h-40"
|
||||
linkHref={`/project/${ref}/logs-explorer?q=${encodeURI(template.searchString)}`}
|
||||
description={template.description}
|
||||
footer={
|
||||
<div className="flex flex-row justify-end">
|
||||
<Popover
|
||||
onOpenChange={setShowPreview}
|
||||
open={showPreview}
|
||||
className="rounded-lg bg-scale-100"
|
||||
size="content"
|
||||
overlay={
|
||||
<pre className="whitespace-pre-line break-words rounded-lg bg-scale-100 p-4 text-sm">
|
||||
{template.searchString}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
as="span"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setShowPreview(!showPreview)
|
||||
}}
|
||||
<div className="scale-100 group-hover:scale-110">
|
||||
<IconCode size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
containerHeightClassName="h-40"
|
||||
linkHref={`/project/${ref}/logs-explorer?q=${encodeURI(template.searchString)}`}
|
||||
description={template.description}
|
||||
footer={
|
||||
<div className="flex flex-row justify-end">
|
||||
<Popover
|
||||
onOpenChange={setShowPreview}
|
||||
open={showPreview}
|
||||
className="rounded-lg bg-scale-100"
|
||||
size="content"
|
||||
overlay={
|
||||
<pre className="whitespace-pre-line break-words rounded-lg bg-scale-100 p-4 text-sm">
|
||||
{template.searchString}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
type="default"
|
||||
as="span"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setShowPreview(!showPreview)
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -356,7 +356,8 @@ const EXTERNAL_PROVIDER_AZURE = {
|
||||
},
|
||||
EXTERNAL_AZURE_SECRET: {
|
||||
// [TODO] Change docs
|
||||
title: 'Secret ID',
|
||||
title: 'Secret Value',
|
||||
description: `Enter the data from Value, not the Secret ID. [Learn more](https://supabase.com/docs/guides/auth/auth-azure#obtain-a-secret-id)`,
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,8 @@
|
||||
},
|
||||
"EXTERNAL_AZURE_SECRET": {
|
||||
"title": "Azure secret",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"help": "Enter the data from Value, not the Secret ID."
|
||||
},
|
||||
"EXTERNAL_AZURE_URL": {
|
||||
"title": "Azure tenant URL",
|
||||
|
||||
Reference in new issue
Block a user