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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is a monorepo containing multiple standalone projects. Each project lives i
code-samples/
├── typesense-angular-search-bar/ # Angular + Typesense search implementation
├── typesense-astro-search/ # Astro + Typesense search implementation
├── typesense-deno-hono-full-text-search/ # Deno (Hono) + Typesense backend implementation
├── typesense-django-full-text-search/ # Python (Django) + Typesense backend implementation
├── typesense-gin-full-text-search/ # Go (Gin) + Typesense backend implementation
├── typesense-kotlin/ # Kotlin (Android) + Typesense search implementation
Expand All @@ -35,6 +36,7 @@ code-samples/
| ---------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------- |
| [typesense-angular-search-bar](./typesense-angular-search-bar) | Angular | A modern search bar with instant search capabilities |
| [typesense-astro-search](./typesense-astro-search) | Astro | A modern search bar with instant search capabilities |
| [typesense-deno-hono-full-text-search](./typesense-deno-hono-full-text-search) | Deno (Hono) | Backend API with full-text search using Typesense |
| [typesense-django-full-text-search](./typesense-django-full-text-search) | Python (Django) | Backend API with full-text search using Typesense |
| [typesense-gin-full-text-search](./typesense-gin-full-text-search) | Go (Gin) | Backend API with full-text search using Typesense |
| [typesense-kotlin](./typesense-kotlin) | Kotlin (Android) | A native Android search bar with instant search capabilities |
Expand Down
15 changes: 15 additions & 0 deletions typesense-deno-hono-full-text-search/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
PORT=3000

# PostgreSQL
DATABASE_URL=postgres://<username>:<password>@<host>:<port>/<database>

# Typesense
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=xyz
TYPESENSE_COLLECTION=books

# Sync tuning
SYNC_INTERVAL_SECONDS=60
SYNC_BATCH_SIZE=1000
4 changes: 4 additions & 0 deletions typesense-deno-hono-full-text-search/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
deno.lock
node_modules/
typesense-data/
57 changes: 57 additions & 0 deletions typesense-deno-hono-full-text-search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Typesense Deno + Hono Full-Text Search App

A production-ready RESTful search API built with Deno, Hono, PostgreSQL, and Typesense.

This application maintains PostgreSQL as the primary source of truth while keeping Typesense synchronously and asynchronously updated to handle fast, typo-tolerant full-text searches.

## Features
- **Deno Native**: No bundler and no build step. Dependencies are declared in `deno.json`.
- **Plain SQL**: Queries are hand-written with [postgres.js](https://github.com/porsager/postgres). No ORM and no migration tool.
- **Batched Incremental Sync**: Handles millions of rows without memory bloat using keyset pagination.
- **Soft Delete Support**: Properly handles `deleted_at` fields and purges ghosts from Typesense.
- **Background Worker**: Keeps the database and Typesense index synchronized automatically.

## Prerequisites
- Deno v2+
- Docker

## Setup & Running

1. **Start Typesense and PostgreSQL:**
```bash
docker run -d -p 8108:8108 \
-v "$(pwd)"/typesense-data:/data \
typesense/typesense:30.2 \
--data-dir /data \
--api-key=xyz \
--enable-cors

docker run -d \
--name local_postgres \
-e POSTGRES_USER=admin \
-e POSTGRES_PASSWORD=admin123 \
-e POSTGRES_DB=typesense_books \
-p 5432:5432 \
postgres:16
```

If PostgreSQL is already running locally on port `5432`, map the container to a free port instead (for example `-p 5433:5432`) and update `DATABASE_URL` to match.

2. **Set up environment variables:**
Copy the template and fill in your PostgreSQL and Typesense values.
```bash
cp .env.example .env
```

3. **Create the database schema:**
Apply `db/schema.sql`, which creates the `books` table, its indexes, the `updated_at` trigger, and a few seed books.
```bash
deno task db:migrate
```

4. **Start the application:**
```bash
deno task dev
```

The app will connect to PostgreSQL, initialize the Typesense collection, perform a startup sync (if needed), start the background sync worker, and bind to `http://localhost:3000`.
40 changes: 40 additions & 0 deletions typesense-deno-hono-full-text-search/db/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS books (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
authors TEXT[] NOT NULL DEFAULT '{}',
publication_year INTEGER,
average_rating NUMERIC(3, 2),
image_url VARCHAR(512),
ratings_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS books_updated_at_idx ON books (updated_at);
CREATE INDEX IF NOT EXISTS books_deleted_at_idx ON books (deleted_at);
CREATE INDEX IF NOT EXISTS books_active_id_idx ON books (id) WHERE deleted_at IS NULL;

CREATE OR REPLACE FUNCTION books_touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS books_touch_updated_at ON books;
CREATE TRIGGER books_touch_updated_at
BEFORE UPDATE ON books
FOR EACH ROW
EXECUTE FUNCTION books_touch_updated_at();

INSERT INTO books (title, authors, publication_year, average_rating, image_url, ratings_count)
SELECT * FROM (
VALUES
('Harry Potter and the Philosopher''s Stone', ARRAY['J.K. Rowling'], 1997, 4.47, 'https://covers.openlibrary.org/b/id/10521270-L.jpg', 9278000),
('The Hobbit', ARRAY['J.R.R. Tolkien'], 1937, 4.28, 'https://covers.openlibrary.org/b/id/6979861-L.jpg', 3400000),
('Dune', ARRAY['Frank Herbert'], 1965, 4.25, 'https://covers.openlibrary.org/b/id/11481354-L.jpg', 1100000),
('Good Omens', ARRAY['Terry Pratchett', 'Neil Gaiman'], 1990, 4.26, 'https://covers.openlibrary.org/b/id/8231990-L.jpg', 600000),
('The Left Hand of Darkness', ARRAY['Ursula K. Le Guin'], 1969, 4.07, 'https://covers.openlibrary.org/b/id/8231856-L.jpg', 180000)
) AS seed (title, authors, publication_year, average_rating, image_url, ratings_count)
WHERE NOT EXISTS (SELECT 1 FROM books);
28 changes: 28 additions & 0 deletions typesense-deno-hono-full-text-search/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "typesense-deno-hono-full-text-search",
"version": "1.0.0",
"exports": "./src/server.ts",
"tasks": {
"dev": "deno run --allow-net --allow-env --allow-read --allow-sys --env-file=.env --watch src/server.ts",
"start": "deno run --allow-net --allow-env --allow-read --allow-sys --env-file=.env src/server.ts",
"db:migrate": "deno run --allow-net --allow-env --allow-read --allow-sys --env-file=.env src/migrate.ts",
"check": "deno check src/server.ts src/migrate.ts"
},
"imports": {
"hono": "jsr:@hono/hono@^4.13.4",
"hono/cors": "jsr:@hono/hono@^4.13.4/cors",
"postgres": "npm:postgres@^3.4.9",
"typesense": "npm:typesense@^3.0.6"
},
"compilerOptions": {
"strict": true
},
"fmt": {
"include": [
"src/"
],
"lineWidth": 100,
"singleQuote": true
},
"nodeModulesDir": "auto"
}
7 changes: 7 additions & 0 deletions typesense-deno-hono-full-text-search/src/config/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import postgres from 'postgres';
import { env } from './env.ts';

export const sql = postgres(env.DATABASE_URL, {
max: 10,
onnotice: (notice) => console.log(`postgres notice: ${notice.message}`),
});
34 changes: 34 additions & 0 deletions typesense-deno-hono-full-text-search/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const REQUIRED = [
'DATABASE_URL',
'TYPESENSE_HOST',
'TYPESENSE_PORT',
'TYPESENSE_PROTOCOL',
'TYPESENSE_API_KEY',
'TYPESENSE_COLLECTION',
] as const;

for (const key of REQUIRED) {
if (!Deno.env.get(key)) {
throw new Error(`Missing required environment variable: ${key}`);
}
}

const number = (key: string, fallback: number): number => {
const raw = Deno.env.get(key);
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) throw new Error(`Environment variable ${key} must be a number`);
return parsed;
};

export const env = {
PORT: number('PORT', 3000),
DATABASE_URL: Deno.env.get('DATABASE_URL')!,
TYPESENSE_HOST: Deno.env.get('TYPESENSE_HOST')!,
TYPESENSE_PORT: number('TYPESENSE_PORT', 8108),
TYPESENSE_PROTOCOL: Deno.env.get('TYPESENSE_PROTOCOL')!,
TYPESENSE_API_KEY: Deno.env.get('TYPESENSE_API_KEY')!,
TYPESENSE_COLLECTION: Deno.env.get('TYPESENSE_COLLECTION')!,
SYNC_INTERVAL_SECONDS: number('SYNC_INTERVAL_SECONDS', 60),
SYNC_BATCH_SIZE: number('SYNC_BATCH_SIZE', 1000),
};
121 changes: 121 additions & 0 deletions typesense-deno-hono-full-text-search/src/db/books.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { sql } from '../config/database.ts';

export interface Book {
id: number;
title: string;
authors: string[];
publication_year: number | null;
average_rating: string | null;
image_url: string | null;
ratings_count: number;
created_at: Date;
updated_at: Date;
deleted_at: Date | null;
}

export interface BookInput {
title?: string;
authors?: string[];
publication_year?: number | null;
average_rating?: number | null;
image_url?: string | null;
ratings_count?: number;
}

export async function dbNow(): Promise<Date> {
const [row] = await sql<{ now: Date }[]>`SELECT now() AS now`;
return row.now;
}

export async function countActiveBooks(): Promise<number> {
const [row] = await sql<{ total: string }[]>`
SELECT count(*)::text AS total FROM books WHERE deleted_at IS NULL`;
return Number(row.total);
}

export async function listActiveBooks(limit: number, offset: number): Promise<Book[]> {
return await sql<Book[]>`
SELECT * FROM books
WHERE deleted_at IS NULL
ORDER BY id
LIMIT ${limit} OFFSET ${offset}`;
}

export async function findActiveBookById(id: number): Promise<Book | undefined> {
const [row] = await sql<Book[]>`
SELECT * FROM books WHERE id = ${id} AND deleted_at IS NULL`;
return row;
}

export async function insertBook(input: BookInput): Promise<Book> {
const [row] = await sql<Book[]>`
INSERT INTO books (title, authors, publication_year, average_rating, image_url, ratings_count)
VALUES (
${input.title ?? ''},
${input.authors ?? []},
${input.publication_year ?? null},
${input.average_rating ?? null},
${input.image_url ?? null},
${input.ratings_count ?? 0}
)
RETURNING *`;
return row;
}

export async function updateBook(id: number, input: BookInput): Promise<Book | undefined> {
const patch: Record<string, unknown> = {};
if (input.title !== undefined) patch.title = input.title;
if (input.authors !== undefined) patch.authors = input.authors;
if (input.publication_year !== undefined) patch.publication_year = input.publication_year;
if (input.average_rating !== undefined) patch.average_rating = input.average_rating;
if (input.image_url !== undefined) patch.image_url = input.image_url;
if (input.ratings_count !== undefined) patch.ratings_count = input.ratings_count;

if (Object.keys(patch).length === 0) return await findActiveBookById(id);

const [row] = await sql<Book[]>`
UPDATE books SET ${sql(patch)}
WHERE id = ${id} AND deleted_at IS NULL
RETURNING *`;
return row;
}

export async function softDeleteBook(id: number): Promise<Book | undefined> {
const [row] = await sql<Book[]>`
UPDATE books SET deleted_at = now()
WHERE id = ${id} AND deleted_at IS NULL
RETURNING *`;
return row;
}

export async function fetchActiveBooksAfterId(lastId: number, limit: number): Promise<Book[]> {
return await sql<Book[]>`
SELECT * FROM books
WHERE id > ${lastId} AND deleted_at IS NULL
ORDER BY id
LIMIT ${limit}`;
}

export async function fetchBooksUpdatedSince(
since: Date,
lastId: number,
limit: number,
): Promise<Book[]> {
return await sql<Book[]>`
SELECT * FROM books
WHERE updated_at > ${since} AND deleted_at IS NULL AND id > ${lastId}
ORDER BY id
LIMIT ${limit}`;
}

export async function fetchBooksDeletedSince(
since: Date,
lastId: number,
limit: number,
): Promise<{ id: number }[]> {
return await sql<{ id: number }[]>`
SELECT id FROM books
WHERE deleted_at > ${since} AND id > ${lastId}
ORDER BY id
LIMIT ${limit}`;
}
16 changes: 16 additions & 0 deletions typesense-deno-hono-full-text-search/src/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { sql } from './config/database.ts';

const schemaPath = new URL('../db/schema.sql', import.meta.url);
const ddl = await Deno.readTextFile(schemaPath);

console.log('Applying db/schema.sql...');

try {
await sql.unsafe(ddl).simple();
console.log('Schema applied successfully.');
} catch (error) {
console.error('Migration failed:', error);
Deno.exit(1);
} finally {
await sql.end();
}
Loading