diff --git a/README.md b/README.md
index ed9054d..7b39b60 100644
--- a/README.md
+++ b/README.md
@@ -8,16 +8,22 @@ This is a monorepo containing multiple standalone projects. Each project lives i
```plaintext
code-samples/
-├── typesense-angular-search-bar/ # Angular + Typesense search implementation
-├── typesense-astro-search/ # Astro + Typesense search implementation
-├── typesense-gin-full-text-search/ # Go (Gin) + Typesense backend implementation
-├── typesense-next-search-bar/ # Next.js + Typesense search implementation
-├── typesense-nuxt-search-bar/ # Nuxt.js + Typesense search implementation
-├── typesense-qwik-js-search/ # Qwik + Typesense search implementation
-├── typesense-react-native-search-bar/ # React Native + Typesense search implementation
-├── typesense-solid-js-search/ # SolidJS + Typesense search implementation
-├── typesense-vanilla-js-search/ # Vanilla JS + Typesense search implementation
-└── README.md # You are here
+├── typesense-angular-search-bar/ # Angular + Typesense search implementation
+├── typesense-astro-search/ # Astro + Typesense search implementation
+├── typesense-gin-full-text-search/ # Go (Gin) + Typesense backend implementation
+├── typesense-kotlin/ # Kotlin (Android) + Typesense search implementation
+├── typesense-next-search-bar/ # Next.js + Typesense search implementation
+├── typesense-nuxt-search-bar/ # Nuxt.js + Typesense search implementation
+├── typesense-qwik-js-search/ # Qwik + Typesense search implementation
+├── typesense-react-native-search-bar/ # React Native + Typesense search implementation
+├── typesense-solid-js-search/ # SolidJS + Typesense search implementation
+├── typesense-springboot-full-text-search/ # Spring Boot + Typesense backend implementation
+├── typesense-node-prisma-full-text-search/ # Node.js (Express) + Typesense + Prisma backend implementation
+├── typesense-node-sequelize-full-text-search/ # Node.js (Express) + Typesense + Sequelize backend implementation
+├── typesense-node-drizzle-full-text-search/ # Node.js (Express) + Typesense + Drizzle backend implementation
+├── typesense-swift-search/ # Swift (iOS) + Typesense search implementation
+├── typesense-vanilla-js-search/ # Vanilla JS + Typesense search implementation
+└── README.md # You are here
```
## Projects
@@ -27,11 +33,17 @@ 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-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 |
| [typesense-next-search-bar](./typesense-next-search-bar) | Next.js | A modern search bar with instant search capabilities |
| [typesense-nuxt-search-bar](./typesense-nuxt-search-bar) | Nuxt.js | A modern search bar with instant search capabilities |
| [typesense-qwik-js-search](./typesense-qwik-js-search) | Qwik | Resumable search bar with real-time search and modern UI |
| [typesense-react-native-search-bar](./typesense-react-native-search-bar) | React Native | A mobile search bar with instant search capabilities |
| [typesense-solid-js-search](./typesense-solid-js-search) | SolidJS | A modern search bar with instant search capabilities |
+| [typesense-springboot-full-text-search](./typesense-springboot-full-text-search) | Spring Boot | Backend API with full-text search using Typesense |
+| [typesense-node-prisma-full-text-search](./typesense-node-prisma-full-text-search) | Node.js (Express) + Typesense + Prisma | Backend API with full-text search using Typesense |
+| [typesense-node-sequelize-full-text-search](./typesense-node-sequelize-full-text-search) | Node.js (Express) + Typesense + Sequelize | Backend API with full-text search using Typesense |
+| [typesense-node-drizzle-search-app](./typesense-node-drizzle-search-app) | Node.js (Express) + Typesense + Drizzle | Backend API with full-text search using Typesense |
+| [typesense-swift-search](./typesense-swift-search) | Swift (iOS) | A native iOS search app with instant search capabilities |
| [typesense-vanilla-js-search](./typesense-vanilla-js-search) | Vanilla JS | A modern search bar with instant search capabilities |
## Getting Started
diff --git a/typesense-kotlin-search/.gitignore b/typesense-kotlin-search/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/typesense-kotlin-search/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/typesense-kotlin-search/app/.gitignore b/typesense-kotlin-search/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/typesense-kotlin-search/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/build.gradle.kts b/typesense-kotlin-search/app/build.gradle.kts
new file mode 100644
index 0000000..f34802e
--- /dev/null
+++ b/typesense-kotlin-search/app/build.gradle.kts
@@ -0,0 +1,54 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "org.typesense.samplekotlin"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "org.typesense.samplekotlin"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(libs.typesense)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.coil)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/typesense-kotlin-search/app/proguard-rules.pro b/typesense-kotlin-search/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/typesense-kotlin-search/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/androidTest/java/org/typesense/samplekotlin/ExampleInstrumentedTest.kt b/typesense-kotlin-search/app/src/androidTest/java/org/typesense/samplekotlin/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..081eaf1
--- /dev/null
+++ b/typesense-kotlin-search/app/src/androidTest/java/org/typesense/samplekotlin/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package org.typesense.samplekotlin
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("org.typesense.samplekotlin", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/AndroidManifest.xml b/typesense-kotlin-search/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ba2e1dd
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/data/repository/TypesenseBookRepository.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/data/repository/TypesenseBookRepository.kt
new file mode 100644
index 0000000..0e2617b
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/data/repository/TypesenseBookRepository.kt
@@ -0,0 +1,32 @@
+package org.typesense.samplekotlin.data.repository
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.typesense.api.Client
+import org.typesense.model.SearchParameters
+import org.typesense.samplekotlin.domain.model.Book
+import org.typesense.samplekotlin.domain.repository.BookRepository
+
+class TypesenseBookRepository(private val client: Client) : BookRepository {
+
+ override suspend fun searchBooks(query: String): List = withContext(Dispatchers.IO) {
+ val searchParameters = SearchParameters()
+ .q(query)
+ .queryBy("title,authors")
+ .sortBy("average_rating:desc")
+
+ val searchResult = client.collections("books").documents().search(searchParameters)
+
+ searchResult.hits?.map { hit ->
+ val document = hit.document
+ Book(
+ id = document["id"]?.toString() ?: "",
+ title = document["title"]?.toString() ?: "",
+ authors = (document["authors"] as? List<*>)?.map { it.toString() } ?: emptyList(),
+ publicationYear = (document["publication_year"] as? Double)?.toInt(),
+ imageUrl = document["image_url"]?.toString(),
+ averageRating = document["average_rating"] as? Double
+ )
+ } ?: emptyList()
+ }
+}
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/model/Book.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/model/Book.kt
new file mode 100644
index 0000000..798946a
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/model/Book.kt
@@ -0,0 +1,10 @@
+package org.typesense.samplekotlin.domain.model
+
+data class Book(
+ val id: String,
+ val title: String,
+ val authors: List,
+ val publicationYear: Int?,
+ val imageUrl: String?,
+ val averageRating: Double?
+)
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/repository/BookRepository.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/repository/BookRepository.kt
new file mode 100644
index 0000000..e445642
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/repository/BookRepository.kt
@@ -0,0 +1,7 @@
+package org.typesense.samplekotlin.domain.repository
+
+import org.typesense.samplekotlin.domain.model.Book
+
+interface BookRepository {
+ suspend fun searchBooks(query: String): List
+}
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/usecase/SearchBooksUseCase.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/usecase/SearchBooksUseCase.kt
new file mode 100644
index 0000000..ac81c10
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/domain/usecase/SearchBooksUseCase.kt
@@ -0,0 +1,18 @@
+package org.typesense.samplekotlin.domain.usecase
+
+import org.typesense.samplekotlin.domain.model.Book
+import org.typesense.samplekotlin.domain.repository.BookRepository
+
+class SearchBooksUseCase(private val repository: BookRepository) {
+ suspend operator fun invoke(query: String): Result> {
+ return try {
+ if (query.isBlank()) {
+ Result.success(emptyList())
+ } else {
+ Result.success(repository.searchBooks(query))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/BookAdapter.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/BookAdapter.kt
new file mode 100644
index 0000000..1a9c3e5
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/BookAdapter.kt
@@ -0,0 +1,42 @@
+package org.typesense.samplekotlin.presentation
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import org.typesense.samplekotlin.databinding.ItemBookBinding
+import org.typesense.samplekotlin.domain.model.Book
+
+class BookAdapter : ListAdapter(BookDiffCallback()) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
+ val binding = ItemBookBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return BookViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ class BookViewHolder(private val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(book: Book) {
+ binding.titleTextView.text = book.title
+ binding.authorsTextView.text = book.authors.joinToString(", ")
+ binding.yearTextView.text = book.publicationYear?.toString() ?: ""
+ binding.ratingBar.rating = book.averageRating?.toFloat() ?: 0f
+
+ binding.bookCoverImageView.load(book.imageUrl) {
+ crossfade(true)
+ placeholder(android.R.color.darker_gray)
+ error(android.R.color.darker_gray)
+ }
+ }
+ }
+
+ class BookDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean = oldItem.id == newItem.id
+ override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean = oldItem == newItem
+ }
+}
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/BookViewModel.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/BookViewModel.kt
new file mode 100644
index 0000000..ae94764
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/BookViewModel.kt
@@ -0,0 +1,40 @@
+package org.typesense.samplekotlin.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.typesense.samplekotlin.domain.model.Book
+import org.typesense.samplekotlin.domain.usecase.SearchBooksUseCase
+
+class BookViewModel(private val searchBooksUseCase: SearchBooksUseCase) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(BookUiState.Idle)
+ val uiState: StateFlow = _uiState
+
+ fun search(query: String) {
+ if (query.isBlank()) {
+ _uiState.value = BookUiState.Idle
+ return
+ }
+
+ _uiState.value = BookUiState.Loading
+ viewModelScope.launch {
+ searchBooksUseCase(query)
+ .onSuccess { books ->
+ _uiState.value = BookUiState.Success(books)
+ }
+ .onFailure { error ->
+ _uiState.value = BookUiState.Error(error.message ?: "Unknown error")
+ }
+ }
+ }
+}
+
+sealed class BookUiState {
+ object Idle : BookUiState()
+ object Loading : BookUiState()
+ data class Success(val books: List) : BookUiState()
+ data class Error(val message: String) : BookUiState()
+}
diff --git a/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/MainActivity.kt b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/MainActivity.kt
new file mode 100644
index 0000000..92194f0
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/java/org/typesense/samplekotlin/presentation/MainActivity.kt
@@ -0,0 +1,105 @@
+package org.typesense.samplekotlin.presentation
+
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.widget.addTextChangedListener
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.typesense.api.Client
+import org.typesense.api.Configuration
+import org.typesense.resources.Node
+import org.typesense.samplekotlin.data.repository.TypesenseBookRepository
+import org.typesense.samplekotlin.databinding.ActivityMainBinding
+import org.typesense.samplekotlin.domain.usecase.SearchBooksUseCase
+import java.time.Duration
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var viewModel: BookViewModel
+ private var searchJob: Job? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ setupViewModel()
+ setupRecyclerView()
+ setupSearch()
+ observeUiState()
+
+ // Initial search to show all books
+ viewModel.search("*")
+ }
+
+ private fun setupViewModel() {
+ // Local Typesense configuration
+ // 10.0.2.2 is the special IP address to access the host machine from the Android emulator.
+ // If you are using a physical device, use your machine's local IP (e.g., 192.168.1.x).
+ val nodes = listOf(Node("http", "10.0.2.2", "8108"))
+ val configuration = Configuration(nodes, Duration.ofSeconds(2), "xyz")
+ val client = Client(configuration)
+
+ val repository = TypesenseBookRepository(client)
+ val useCase = SearchBooksUseCase(repository)
+
+ viewModel = ViewModelProvider(this, object : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST")
+ return BookViewModel(useCase) as T
+ }
+ })[BookViewModel::class.java]
+ }
+
+ private fun setupRecyclerView() {
+ val adapter = BookAdapter()
+ binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
+ binding.recyclerView.adapter = adapter
+ }
+
+ private fun setupSearch() {
+ binding.searchEditText.addTextChangedListener { text ->
+ searchJob?.cancel()
+ searchJob = lifecycleScope.launch {
+ delay(300) // Debounce search
+ viewModel.search(text?.toString() ?: " ")
+ }
+ }
+ }
+
+ private fun observeUiState() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state ->
+ when (state) {
+ is BookUiState.Idle -> {
+ binding.progressBar.visibility = View.GONE
+ }
+ is BookUiState.Loading -> {
+ binding.progressBar.visibility = View.VISIBLE
+ }
+ is BookUiState.Success -> {
+ binding.progressBar.visibility = View.GONE
+ (binding.recyclerView.adapter as BookAdapter).submitList(state.books)
+ }
+ is BookUiState.Error -> {
+ binding.progressBar.visibility = View.GONE
+ Toast.makeText(this@MainActivity, state.message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/typesense-kotlin-search/app/src/main/res/drawable/badge_bg.xml b/typesense-kotlin-search/app/src/main/res/drawable/badge_bg.xml
new file mode 100644
index 0000000..447fd04
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/drawable/badge_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/drawable/ic_kotlin_logo.xml b/typesense-kotlin-search/app/src/main/res/drawable/ic_kotlin_logo.xml
new file mode 100644
index 0000000..c652e83
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/drawable/ic_kotlin_logo.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/drawable/ic_launcher_background.xml b/typesense-kotlin-search/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/typesense-kotlin-search/app/src/main/res/drawable/ic_launcher_foreground.xml b/typesense-kotlin-search/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/drawable/search_bg.xml b/typesense-kotlin-search/app/src/main/res/drawable/search_bg.xml
new file mode 100644
index 0000000..0f25174
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/drawable/search_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/layout/activity_main.xml b/typesense-kotlin-search/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..f62c7af
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/layout/item_book.xml b/typesense-kotlin-search/app/src/main/res/layout/item_book.xml
new file mode 100644
index 0000000..8742957
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/layout/item_book.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/typesense-kotlin-search/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/typesense-kotlin-search/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/typesense-kotlin-search/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/typesense-kotlin-search/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/typesense-kotlin-search/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/typesense-kotlin-search/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/typesense-kotlin-search/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/typesense-kotlin-search/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/typesense-kotlin-search/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/typesense-kotlin-search/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/typesense-kotlin-search/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/typesense-kotlin-search/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/typesense-kotlin-search/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/typesense-kotlin-search/app/src/main/res/values-night/themes.xml b/typesense-kotlin-search/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..0883fc5
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,17 @@
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/values/colors.xml b/typesense-kotlin-search/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..1916832
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/values/colors.xml
@@ -0,0 +1,14 @@
+
+
+ #1A1A1A
+ #2A2A2A
+ #333333
+ #FFFFFF
+ #B0B0B0
+ #808080
+ #EF5350
+ #61DAFB
+ #7F52FF
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/values/strings.xml b/typesense-kotlin-search/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..54b2402
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Typesense with Kotlin
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/values/themes.xml b/typesense-kotlin-search/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..0883fc5
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/values/themes.xml
@@ -0,0 +1,17 @@
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/xml/backup_rules.xml b/typesense-kotlin-search/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/main/res/xml/data_extraction_rules.xml b/typesense-kotlin-search/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/typesense-kotlin-search/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/typesense-kotlin-search/app/src/test/java/org/typesense/samplekotlin/ExampleUnitTest.kt b/typesense-kotlin-search/app/src/test/java/org/typesense/samplekotlin/ExampleUnitTest.kt
new file mode 100644
index 0000000..fdbc6af
--- /dev/null
+++ b/typesense-kotlin-search/app/src/test/java/org/typesense/samplekotlin/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package org.typesense.samplekotlin
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/typesense-kotlin-search/build.gradle.kts b/typesense-kotlin-search/build.gradle.kts
new file mode 100644
index 0000000..7f09f7c
--- /dev/null
+++ b/typesense-kotlin-search/build.gradle.kts
@@ -0,0 +1,5 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+}
diff --git a/typesense-kotlin-search/gradle.properties b/typesense-kotlin-search/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/typesense-kotlin-search/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/typesense-kotlin-search/gradle/libs.versions.toml b/typesense-kotlin-search/gradle/libs.versions.toml
new file mode 100644
index 0000000..13066c9
--- /dev/null
+++ b/typesense-kotlin-search/gradle/libs.versions.toml
@@ -0,0 +1,30 @@
+[versions]
+agp = "8.3.2"
+kotlin = "1.9.22"
+coreKtx = "1.12.0"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+appcompat = "1.6.1"
+material = "1.11.0"
+typesense = "2.1.0"
+lifecycle = "2.6.2"
+coroutines = "1.7.3"
+coil = "2.6.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+typesense = { group = "org.typesense", name = "typesense-java", version.ref = "typesense" }
+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/typesense-kotlin-search/gradle/wrapper/gradle-wrapper.jar b/typesense-kotlin-search/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/typesense-kotlin-search/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/typesense-kotlin-search/gradle/wrapper/gradle-wrapper.properties b/typesense-kotlin-search/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e5cfecc
--- /dev/null
+++ b/typesense-kotlin-search/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+#Tue May 12 08:08:11 IST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/typesense-kotlin-search/gradlew b/typesense-kotlin-search/gradlew
new file mode 100755
index 0000000..ef07e01
--- /dev/null
+++ b/typesense-kotlin-search/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/typesense-kotlin-search/gradlew.bat b/typesense-kotlin-search/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/typesense-kotlin-search/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/typesense-kotlin-search/settings.gradle.kts b/typesense-kotlin-search/settings.gradle.kts
new file mode 100644
index 0000000..c6f9110
--- /dev/null
+++ b/typesense-kotlin-search/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Typesense with Kotlin"
+include(":app")
+
\ No newline at end of file
diff --git a/typesense-node-drizzle-full-text-search/README.md b/typesense-node-drizzle-full-text-search/README.md
new file mode 100644
index 0000000..e0d9edb
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/README.md
@@ -0,0 +1,54 @@
+# Typesense Node.js Drizzle ORM Full-Text Search App
+
+A production-ready RESTful search API built with Node.js, Express, Drizzle ORM, 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
+- **Drizzle ORM Integration**: High performance, strongly-typed PostgreSQL queries.
+- **Batched Incremental Sync**: Handles millions of rows without memory bloat using cursor-based pagination.
+- **Soft Delete Support**: Properly handles `deleted_at` fields and purges ghosts from Typesense.
+- **Cron Jobs**: Background worker keeps the database and Typesense index synchronized automatically.
+
+## Prerequisites
+- Node.js v18+
+- Docker
+
+## Setup & Running
+
+1. **Start Typesense and PostgreSQL:**
+```bash
+docker run -d -p 8108:8108 \
+ -v "$(pwd)"/typesense-data:/data \
+ typesense/typesense:27.1 \
+ --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=testdb \
+ -p 5432:5432 \
+ postgres:16
+```
+
+2. **Install dependencies:**
+```bash
+npm install
+```
+
+3. **Generate and Run Migrations:**
+Generate Drizzle migrations from `src/db/schema.ts` and push them to the database.
+```bash
+npx drizzle-kit generate
+npx drizzle-kit push
+```
+
+4. **Start the application:**
+```bash
+npm run dev
+```
+
+The app will connect to PostgreSQL, initialize Typesense schemas, perform a startup sync (if needed), start the cron worker, and bind to `http://localhost:3002`.
diff --git a/typesense-node-drizzle-full-text-search/drizzle.config.ts b/typesense-node-drizzle-full-text-search/drizzle.config.ts
new file mode 100644
index 0000000..789f7a2
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/drizzle.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'drizzle-kit';
+import 'dotenv/config';
+
+export default defineConfig({
+ schema: './src/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: process.env.DATABASE_URL!,
+ },
+});
diff --git a/typesense-node-drizzle-full-text-search/drizzle/0000_aromatic_spiral.sql b/typesense-node-drizzle-full-text-search/drizzle/0000_aromatic_spiral.sql
new file mode 100644
index 0000000..a35661c
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/drizzle/0000_aromatic_spiral.sql
@@ -0,0 +1,12 @@
+CREATE TABLE "books" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "title" varchar(255) NOT NULL,
+ "authors" json DEFAULT '[]' NOT NULL,
+ "publication_year" integer,
+ "average_rating" numeric(3, 2),
+ "image_url" varchar(255),
+ "ratings_count" integer,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ "deleted_at" timestamp
+);
diff --git a/typesense-node-drizzle-full-text-search/drizzle/meta/0000_snapshot.json b/typesense-node-drizzle-full-text-search/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..aa834f0
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,95 @@
+{
+ "id": "527348cf-95fc-4d6b-bb93-7beac078119f",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.books": {
+ "name": "books",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authors": {
+ "name": "authors",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'"
+ },
+ "publication_year": {
+ "name": "publication_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "average_rating": {
+ "name": "average_rating",
+ "type": "numeric(3, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ratings_count": {
+ "name": "ratings_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/typesense-node-drizzle-full-text-search/drizzle/meta/_journal.json b/typesense-node-drizzle-full-text-search/drizzle/meta/_journal.json
new file mode 100644
index 0000000..8ba891d
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/drizzle/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1777712202767,
+ "tag": "0000_aromatic_spiral",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/typesense-node-drizzle-full-text-search/package.json b/typesense-node-drizzle-full-text-search/package.json
new file mode 100644
index 0000000..caeb1ec
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "typesense-node-drizzle-full-text-search",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
+ "db:generate": "drizzle-kit generate",
+ "db:push": "drizzle-kit push",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "dependencies": {
+ "cors": "^2.8.6",
+ "dotenv": "^17.4.2",
+ "drizzle-orm": "^0.45.2",
+ "express": "^5.2.1",
+ "node-cron": "^4.2.1",
+ "pg": "^8.20.0",
+ "typesense": "^3.0.6"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.6",
+ "@types/node": "^25.6.0",
+ "@types/node-cron": "^3.0.11",
+ "@types/pg": "^8.20.0",
+ "drizzle-kit": "^0.31.10",
+ "ts-node-dev": "^2.0.0",
+ "typescript": "^6.0.3"
+ }
+}
diff --git a/typesense-node-drizzle-full-text-search/src/config/database.ts b/typesense-node-drizzle-full-text-search/src/config/database.ts
new file mode 100644
index 0000000..55c64ff
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/config/database.ts
@@ -0,0 +1,12 @@
+import { drizzle } from 'drizzle-orm/node-postgres';
+import { Pool } from 'pg';
+import { env } from './env';
+import * as schema from '../db/schema';
+
+// Create a pg pool
+const pool = new Pool({
+ connectionString: env.DATABASE_URL,
+});
+
+// Create the Drizzle instance
+export const db = drizzle(pool, { schema });
diff --git a/typesense-node-drizzle-full-text-search/src/config/env.ts b/typesense-node-drizzle-full-text-search/src/config/env.ts
new file mode 100644
index 0000000..8d7fc44
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/config/env.ts
@@ -0,0 +1,27 @@
+import * as dotenv from 'dotenv';
+dotenv.config();
+
+const requiredEnvs = [
+ 'DATABASE_URL',
+ 'TYPESENSE_HOST',
+ 'TYPESENSE_PORT',
+ 'TYPESENSE_PROTOCOL',
+ 'TYPESENSE_API_KEY',
+ 'TYPESENSE_COLLECTION',
+] as const;
+
+for (const env of requiredEnvs) {
+ if (!process.env[env]) {
+ throw new Error(`Missing required environment variable: ${env}`);
+ }
+}
+
+export const env = {
+ PORT: process.env.PORT || 3000,
+ DATABASE_URL: process.env.DATABASE_URL!,
+ TYPESENSE_HOST: process.env.TYPESENSE_HOST!,
+ TYPESENSE_PORT: parseInt(process.env.TYPESENSE_PORT!, 10),
+ TYPESENSE_PROTOCOL: process.env.TYPESENSE_PROTOCOL!,
+ TYPESENSE_API_KEY: process.env.TYPESENSE_API_KEY!,
+ TYPESENSE_COLLECTION: process.env.TYPESENSE_COLLECTION!,
+};
diff --git a/typesense-node-drizzle-full-text-search/src/db/schema.ts b/typesense-node-drizzle-full-text-search/src/db/schema.ts
new file mode 100644
index 0000000..4ed7b77
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/db/schema.ts
@@ -0,0 +1,18 @@
+import { pgTable, serial, varchar, json, integer, decimal, timestamp } from 'drizzle-orm/pg-core';
+import { sql } from 'drizzle-orm';
+
+export const books = pgTable('books', {
+ id: serial('id').primaryKey(),
+ title: varchar('title', { length: 255 }).notNull(),
+ authors: json('authors').default('[]').notNull(),
+ publicationYear: integer('publication_year'),
+ averageRating: decimal('average_rating', { precision: 3, scale: 2 }),
+ imageUrl: varchar('image_url', { length: 255 }),
+ ratingsCount: integer('ratings_count'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).$onUpdate(() => sql`CURRENT_TIMESTAMP`).notNull(),
+ deletedAt: timestamp('deleted_at'),
+});
+
+export type Book = typeof books.$inferSelect;
+export type NewBook = typeof books.$inferInsert;
diff --git a/typesense-node-drizzle-full-text-search/src/routes/books.ts b/typesense-node-drizzle-full-text-search/src/routes/books.ts
new file mode 100644
index 0000000..60885d6
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/routes/books.ts
@@ -0,0 +1,137 @@
+import { Router, type Request, type Response } from 'express';
+import { db } from '../config/database';
+import { books, type Book } from '../db/schema';
+import { eq, isNull, count } from 'drizzle-orm';
+import { typesenseClient } from '../search/client';
+import { BOOKS_COLLECTION_NAME } from '../search/collections';
+
+const router = Router();
+
+const syncBookToTypesense = async (book: Book) => {
+ try {
+ const authorsArray = Array.isArray(book.authors) ? book.authors : [book.authors];
+
+ const document = {
+ id: book.id.toString(),
+ title: book.title,
+ authors: authorsArray as string[],
+ publication_year: book.publicationYear || 0,
+ average_rating: book.averageRating ? Number(book.averageRating) : 0,
+ image_url: book.imageUrl || '',
+ ratings_count: book.ratingsCount || 0,
+ };
+
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().upsert(document);
+ } catch (err) {
+ console.error(`Failed to sync book ${book.id} to Typesense:`, err);
+ throw err;
+ }
+};
+
+const deleteBookFromTypesense = async (id: number) => {
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents(id.toString()).delete();
+ } catch (err) {
+ console.error(`Failed to delete book ${id} from Typesense`, err);
+ }
+};
+
+router.get('/', async (req: Request, res: Response) => {
+ const page = parseInt(req.query.page as string || '1', 10);
+ const limit = parseInt(req.query.limit as string || '10', 10);
+ const offset = (page - 1) * limit;
+
+ try {
+ const totalCountRes = await db.select({ value: count() }).from(books).where(isNull(books.deletedAt));
+ const totalCount = totalCountRes[0].value;
+
+ const rows = await db.select()
+ .from(books)
+ .where(isNull(books.deletedAt))
+ .limit(limit)
+ .offset(offset)
+ .orderBy(books.id);
+
+ res.json({
+ total: totalCount,
+ page,
+ limit,
+ data: rows
+ });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ error: 'Failed to fetch books' });
+ }
+});
+
+router.get('/:id', async (req: Request, res: Response) => {
+ try {
+ const bookId = parseInt(req.params.id as string);
+ const result = await db.select().from(books).where(eq(books.id, bookId));
+ const book = result.find(b => b.deletedAt === null);
+
+ if (!book) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+ res.json(book);
+ } catch (error) {
+ res.status(500).json({ error: 'Failed to fetch book' });
+ }
+});
+
+router.post('/', async (req: Request, res: Response) => {
+ try {
+ const result = await db.insert(books).values(req.body).returning();
+ const book = result[0];
+
+ await syncBookToTypesense(book);
+
+ res.status(201).json(book);
+ } catch (error) {
+ res.status(400).json({ error: (error as Error).message });
+ }
+});
+
+router.put('/:id', async (req: Request, res: Response) => {
+ try {
+ const bookId = parseInt(req.params.id as string);
+ const existing = await db.select().from(books).where(eq(books.id, bookId));
+
+ if (existing.length === 0 || existing[0].deletedAt !== null) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+
+ const updated = await db.update(books)
+ .set({ ...req.body, updatedAt: new Date() })
+ .where(eq(books.id, bookId))
+ .returning();
+
+ const updatedBook = updated[0];
+ await syncBookToTypesense(updatedBook);
+
+ res.json(updatedBook);
+ } catch (error) {
+ res.status(400).json({ error: (error as Error).message });
+ }
+});
+
+router.delete('/:id', async (req: Request, res: Response) => {
+ try {
+ const bookId = parseInt(req.params.id as string);
+ const existing = await db.select().from(books).where(eq(books.id, bookId));
+
+ if (existing.length === 0 || existing[0].deletedAt !== null) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+
+ await db.update(books).set({ deletedAt: new Date(), updatedAt: new Date() }).where(eq(books.id, bookId));
+
+ await deleteBookFromTypesense(bookId);
+
+ res.status(204).send();
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+export default router;
diff --git a/typesense-node-drizzle-full-text-search/src/routes/search.ts b/typesense-node-drizzle-full-text-search/src/routes/search.ts
new file mode 100644
index 0000000..eb08614
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/routes/search.ts
@@ -0,0 +1,44 @@
+import { Router, type Request, type Response } from 'express';
+import { typesenseClient } from '../search/client';
+import { BOOKS_COLLECTION_NAME } from '../search/collections';
+import { runFullSync } from '../search/sync';
+
+const router = Router();
+
+// Perform search
+router.get('/search', async (req: Request, res: Response) => {
+ const { q, query_by, ...otherParams } = req.query;
+
+ if (!q || !query_by) {
+ return res.status(400).json({ error: 'Missing required query parameters: q and query_by' });
+ }
+
+ try {
+ const searchResults = await typesenseClient
+ .collections(BOOKS_COLLECTION_NAME)
+ .documents()
+ .search({
+ q: q as string,
+ query_by: query_by as string,
+ ...otherParams,
+ });
+
+ res.json(searchResults);
+ } catch (error) {
+ console.error('Search error:', error);
+ res.status(500).json({ error: 'Search failed' });
+ }
+});
+
+// Manual Sync endpoint
+router.post('/sync', async (req: Request, res: Response) => {
+ try {
+ await runFullSync();
+ res.json({ message: 'Sync completed successfully' });
+ } catch (error) {
+ console.error('Sync failed:', error);
+ res.status(500).json({ error: 'Sync failed' });
+ }
+});
+
+export default router;
diff --git a/typesense-node-drizzle-full-text-search/src/search/client.ts b/typesense-node-drizzle-full-text-search/src/search/client.ts
new file mode 100644
index 0000000..b8c403d
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/search/client.ts
@@ -0,0 +1,16 @@
+import { Client } from 'typesense';
+import { env } from '../config/env';
+
+export const typesenseClient = new Client({
+ nodes: [
+ {
+ host: env.TYPESENSE_HOST,
+ port: env.TYPESENSE_PORT,
+ protocol: env.TYPESENSE_PROTOCOL,
+ },
+ ],
+ apiKey: env.TYPESENSE_API_KEY,
+ connectionTimeoutSeconds: 5,
+ retryIntervalSeconds: 1,
+ numRetries: 3,
+});
diff --git a/typesense-node-drizzle-full-text-search/src/search/collections.ts b/typesense-node-drizzle-full-text-search/src/search/collections.ts
new file mode 100644
index 0000000..e3a4489
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/search/collections.ts
@@ -0,0 +1,33 @@
+import { typesenseClient } from './client';
+import { env } from '../config/env';
+import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
+
+export const BOOKS_COLLECTION_NAME = env.TYPESENSE_COLLECTION;
+
+export async function initializeTypesense() {
+ const schema: CollectionCreateSchema = {
+ name: BOOKS_COLLECTION_NAME,
+ fields: [
+ { name: 'title', type: 'string', facet: false },
+ { name: 'authors', type: 'string[]', facet: true },
+ { name: 'publication_year', type: 'int32', facet: true },
+ { name: 'average_rating', type: 'float', facet: true },
+ { name: 'image_url', type: 'string', facet: false },
+ { name: 'ratings_count', type: 'int32', facet: true },
+ ],
+ default_sorting_field: 'ratings_count',
+ };
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).retrieve();
+ console.log(`Collection '${BOOKS_COLLECTION_NAME}' already exists.`);
+ } catch (error: any) {
+ if (error.httpStatus === 404) {
+ console.log(`Collection '${BOOKS_COLLECTION_NAME}' not found. Creating...`);
+ await typesenseClient.collections().create(schema);
+ console.log(`Collection '${BOOKS_COLLECTION_NAME}' created successfully.`);
+ } else {
+ throw error;
+ }
+ }
+}
diff --git a/typesense-node-drizzle-full-text-search/src/search/sync.ts b/typesense-node-drizzle-full-text-search/src/search/sync.ts
new file mode 100644
index 0000000..908de12
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/search/sync.ts
@@ -0,0 +1,188 @@
+import { db } from '../config/database';
+import { books, type Book } from '../db/schema';
+import { typesenseClient } from './client';
+import { BOOKS_COLLECTION_NAME } from './collections';
+import { eq, gt, isNull, and, isNotNull, desc } from 'drizzle-orm';
+
+export let lastSyncTime: Date = new Date(0);
+
+const BATCH_SIZE = 1000;
+
+const mapBookToTypesense = (b: Book) => ({
+ id: b.id.toString(),
+ title: b.title,
+ authors: (Array.isArray(b.authors) ? b.authors : [b.authors]) as string[],
+ publication_year: b.publicationYear || 0,
+ average_rating: b.averageRating ? Number(b.averageRating) : 0,
+ image_url: b.imageUrl || '',
+ ratings_count: b.ratingsCount || 0,
+});
+
+export async function runFullSync() {
+ console.log('Running full sync...');
+ let lastId = 0;
+ let hasMore = true;
+ let totalProcessed = 0;
+
+ while (hasMore) {
+ let fetchedBooks: Book[];
+ try {
+ fetchedBooks = await db.select()
+ .from(books)
+ .where(
+ and(
+ gt(books.id, lastId),
+ isNull(books.deletedAt)
+ )
+ )
+ .limit(BATCH_SIZE)
+ .orderBy(books.id);
+ } catch (err) {
+ console.error('Database error during full sync fetching:', err);
+ break;
+ }
+
+ if (fetchedBooks.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ lastId = fetchedBooks[fetchedBooks.length - 1].id;
+ const documents = fetchedBooks.map(mapBookToTypesense);
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().import(documents, { action: 'upsert' });
+ totalProcessed += documents.length;
+ console.log(`Full sync: Processed ${totalProcessed} books.`);
+ } catch (err) {
+ console.error('Error importing documents during full sync', err);
+ break;
+ }
+ }
+
+ lastSyncTime = new Date();
+ console.log('Full sync completed.');
+}
+
+export async function runIncrementalSync() {
+ console.log(`Running incremental sync since ${lastSyncTime.toISOString()}...`);
+
+ // 1. Process newly created or updated books in batches
+ let lastUpsertId = 0;
+ let hasMoreUpserts = true;
+ let totalUpserted = 0;
+
+ while (hasMoreUpserts) {
+ let updatedBooks: Book[];
+ try {
+ updatedBooks = await db.select()
+ .from(books)
+ .where(
+ and(
+ gt(books.updatedAt, lastSyncTime),
+ isNull(books.deletedAt),
+ gt(books.id, lastUpsertId)
+ )
+ )
+ .limit(BATCH_SIZE)
+ .orderBy(books.id);
+ } catch (err) {
+ console.error('Database error during incremental sync upsert fetching:', err);
+ break;
+ }
+
+ if (updatedBooks.length === 0) {
+ hasMoreUpserts = false;
+ break;
+ }
+
+ lastUpsertId = updatedBooks[updatedBooks.length - 1].id;
+ const documents = updatedBooks.map(mapBookToTypesense);
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().import(documents, { action: 'upsert' });
+ totalUpserted += documents.length;
+ } catch (err) {
+ console.error('Error upserting documents in incremental sync', err);
+ break;
+ }
+ }
+
+ if (totalUpserted > 0) {
+ console.log(`Incremental sync: Upserted ${totalUpserted} books.`);
+ }
+
+ // 2. Process soft-deleted books in batches
+ let lastDeleteId = 0;
+ let hasMoreDeletes = true;
+ let totalDeleted = 0;
+
+ while (hasMoreDeletes) {
+ let deletedBooks: Book[];
+ try {
+ deletedBooks = await db.select()
+ .from(books)
+ .where(
+ and(
+ gt(books.updatedAt, lastSyncTime),
+ isNotNull(books.deletedAt),
+ gt(books.id, lastDeleteId)
+ )
+ )
+ .limit(BATCH_SIZE)
+ .orderBy(books.id);
+ } catch (err) {
+ console.error('Database error during incremental sync delete fetching:', err);
+ break;
+ }
+
+ if (deletedBooks.length === 0) {
+ hasMoreDeletes = false;
+ break;
+ }
+
+ lastDeleteId = deletedBooks[deletedBooks.length - 1].id;
+ const ids = deletedBooks.map(b => b.id.toString());
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().delete({
+ filter_by: `id:=[${ids.join(',')}]`
+ });
+ totalDeleted += deletedBooks.length;
+ } catch (err) {
+ console.error('Error deleting documents in incremental sync', err);
+ break;
+ }
+ }
+
+ if (totalDeleted > 0) {
+ console.log(`Incremental sync: Deleted ${totalDeleted} books from Typesense.`);
+ }
+
+ lastSyncTime = new Date();
+ console.log('Incremental sync completed.');
+}
+
+export async function determineAndRunStartupSync() {
+ try {
+ const searchStats = await typesenseClient.collections(BOOKS_COLLECTION_NAME).retrieve();
+ const docCount = searchStats.num_documents;
+
+ if (docCount === 0) {
+ await runFullSync();
+ } else {
+ const latestBook = await db.select()
+ .from(books)
+ .orderBy(desc(books.updatedAt))
+ .limit(1);
+
+ if (latestBook.length > 0 && latestBook[0].updatedAt) {
+ lastSyncTime = latestBook[0].updatedAt;
+ }
+
+ await runIncrementalSync();
+ }
+ } catch (error) {
+ console.error('Error during startup sync:', error);
+ }
+}
diff --git a/typesense-node-drizzle-full-text-search/src/search/worker.ts b/typesense-node-drizzle-full-text-search/src/search/worker.ts
new file mode 100644
index 0000000..dd35a01
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/search/worker.ts
@@ -0,0 +1,24 @@
+import cron from 'node-cron';
+import { runIncrementalSync } from './sync';
+
+let isSyncRunning = false;
+
+export function startBackgroundSyncWorker() {
+ console.log('Starting background sync worker (every 60 seconds)...');
+
+ cron.schedule('*/60 * * * * *', async () => {
+ if (isSyncRunning) {
+ console.log('Sync already running, skipping this interval.');
+ return;
+ }
+
+ isSyncRunning = true;
+ try {
+ await runIncrementalSync();
+ } catch (err) {
+ console.error('Error during background incremental sync:', err);
+ } finally {
+ isSyncRunning = false;
+ }
+ });
+}
diff --git a/typesense-node-drizzle-full-text-search/src/server.ts b/typesense-node-drizzle-full-text-search/src/server.ts
new file mode 100644
index 0000000..6e94f16
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/src/server.ts
@@ -0,0 +1,41 @@
+import express from 'express';
+import cors from 'cors';
+import { env } from './config/env';
+import { initializeTypesense } from './search/collections';
+import { determineAndRunStartupSync } from './search/sync';
+import { startBackgroundSyncWorker } from './search/worker';
+
+import booksRouter from './routes/books';
+import searchRouter from './routes/search';
+
+const app = express();
+
+app.use(cors());
+app.use(express.json());
+
+// Routes
+app.use('/books', booksRouter);
+app.use('/', searchRouter);
+
+async function startServer() {
+ try {
+ console.log('PostgreSQL database config loaded via Drizzle.');
+
+ console.log('Initializing Typesense...');
+ await initializeTypesense();
+
+ console.log('Running startup sync...');
+ await determineAndRunStartupSync();
+
+ startBackgroundSyncWorker();
+
+ app.listen(env.PORT, () => {
+ console.log(`Server is running on http://localhost:${env.PORT}`);
+ });
+ } catch (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+}
+
+startServer();
diff --git a/typesense-node-drizzle-full-text-search/tsconfig.json b/typesense-node-drizzle-full-text-search/tsconfig.json
new file mode 100644
index 0000000..5dbd728
--- /dev/null
+++ b/typesense-node-drizzle-full-text-search/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/typesense-node-prisma-full-text-search/.env.example b/typesense-node-prisma-full-text-search/.env.example
new file mode 100644
index 0000000..9b6d483
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/.env.example
@@ -0,0 +1,15 @@
+# Server Configuration
+PORT=3000
+
+# Database Configuration
+DB_HOST=localhost
+DB_USER=postgres
+DB_PASSWORD=password
+DB_NAME=typesense_books
+DB_PORT=5432
+
+# Typesense Configuration
+TYPESENSE_HOST=localhost
+TYPESENSE_PORT=8108
+TYPESENSE_PROTOCOL=http
+TYPESENSE_API_KEY=xyz
diff --git a/typesense-node-prisma-full-text-search/.gitignore b/typesense-node-prisma-full-text-search/.gitignore
new file mode 100644
index 0000000..126419d
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+# Keep environment variables out of version control
+.env
+
+/src/generated/prisma
diff --git a/typesense-node-prisma-full-text-search/README.md b/typesense-node-prisma-full-text-search/README.md
new file mode 100644
index 0000000..652bfc0
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/README.md
@@ -0,0 +1,231 @@
+# Node.js Express Full-Text Search with Typesense
+
+A production-ready RESTful search API built with Node.js, Express, PostgreSQL (Prisma), and Typesense. Features full-text search, CRUD operations, real-time async indexing, and background sync workers.
+
+## Tech Stack
+
+- Node.js
+- Express
+- PostgreSQL with Prisma ORM
+- Typesense
+- TypeScript
+- Docker
+
+## Prerequisites
+
+- Node.js v18+ installed
+- Docker (for Typesense and PostgreSQL)
+- Basic knowledge of REST APIs and SQL
+
+## Quick Start
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/typesense/code-samples.git
+cd typesense-node-prisma-full-text-search
+```
+
+### 2. Install dependencies
+
+```bash
+npm install
+```
+
+### 3. Start Typesense and PostgreSQL
+
+Run Typesense and PostgreSQL using Docker:
+
+```bash
+# Start Typesense (replace TYPESENSE_VERSION with the latest from https://typesense.org/docs/guide/install-typesense.html)
+docker run -d \
+ -p 8108:8108 \
+ -v typesense-data:/data \
+ typesense/typesense:27.1 \
+ --data-dir /data \
+ --api-key=xyz \
+ --enable-cors
+
+# Start PostgreSQL
+docker run -d \
+ -p 5432:5432 \
+ -e POSTGRES_USER=postgres \
+ -e POSTGRES_PASSWORD=password \
+ -e POSTGRES_DB=typesense_books \
+ -v postgres-data:/var/lib/postgresql/data \
+ postgres:15
+```
+
+### 4. Set up environment variables
+
+Create a `.env` file in the project root by copying `.env.example`:
+
+```bash
+cp .env.example .env
+```
+
+### 5. Project Structure
+
+```text
+├── prisma/
+│ └── schema.prisma # Prisma schema and model definitions
+├── src/
+│ ├── config/
+│ │ ├── database.ts # Prisma Client instantiation
+│ │ └── env.ts # Environment variable validation
+│ ├── routes/
+│ │ ├── books.ts # CRUD endpoints for books
+│ │ └── search.ts # Search and sync endpoints
+│ ├── search/
+│ │ ├── client.ts # Typesense client initialization
+│ │ ├── collections.ts # Typesense collection schema
+│ │ ├── sync.ts # Sync logic (incremental, full, soft delete)
+│ │ └── worker.ts # Background sync worker
+│ └── server.ts # Main application entry point
+├── package.json
+├── tsconfig.json
+└── .env
+```
+
+### 6. Database Migrations
+
+**Development Environment:**
+When building out your schema or making changes during development, use the `db push` command. It pushes your schema state directly to the database without generating history:
+```bash
+npx prisma db push
+```
+
+**Production Environment:**
+For production, you should use Prisma Migrate to generate and apply consistent database migrations.
+Generate a migration (run this in dev when ready):
+```bash
+npx prisma migrate dev --name init_books
+```
+Apply migrations safely in production (e.g., during your CI/CD pipeline):
+```bash
+npx prisma migrate deploy
+```
+
+### 7. Start the development server
+
+```bash
+npm run dev
+```
+
+The server will automatically restart when you make changes to any TypeScript file.
+
+Open [http://localhost:3000](http://localhost:3000) in your browser.
+
+### 8. API Endpoints
+
+#### Search
+
+```bash
+GET /search?q=
+```
+
+Example:
+
+```bash
+curl "http://localhost:3000/search?q=harry"
+```
+
+#### CRUD Operations
+
+**Create a book:**
+
+```bash
+POST /books
+Content-Type: application/json
+
+{
+ "title": "The Go Programming Language",
+ "authors": ["Alan Donovan", "Brian Kernighan"],
+ "publication_year": 2015,
+ "average_rating": 4.5,
+ "image_url": "https://example.com/image.jpg",
+ "ratings_count": 1000
+}
+```
+
+**Get a book:**
+
+```bash
+GET /books/:id
+```
+
+**Get all books (with pagination):**
+
+```bash
+GET /books?page=1&limit=10
+```
+
+**Update a book:**
+
+```bash
+PUT /books/:id
+Content-Type: application/json
+
+{
+ "title": "Updated Title",
+ "authors": ["Author Name"],
+ "publication_year": 2024,
+ "average_rating": 4.8,
+ "image_url": "https://example.com/updated.jpg",
+ "ratings_count": 1500
+}
+```
+
+**Delete a book (soft delete):**
+
+```bash
+DELETE /books/:id
+```
+
+#### Sync Operations
+
+**Trigger manual sync:**
+
+```bash
+POST /sync
+```
+
+**Check sync status:**
+
+```bash
+GET /sync/status
+```
+
+### 9. How It Works
+
+#### Architecture
+
+```plaintext
+User Request
+ ↓
+Express API (CRUD)
+ ↓
+PostgreSQL (Source of Truth)
+ ↓
+Async Sync → Typesense (Search Index)
+ ↑
+Background Worker (Every 60s)
+```
+
+#### Sync Strategies
+
+##### 1. Startup Sync (Smart)
+
+On every server start, the sync worker checks whether the Typesense collection already has documents. If empty, it seeds `lastSyncTime` to zero and runs a full sync. If it has data, it runs an incremental sync since `MAX(updated_at)` of PostgreSQL books table.
+
+##### 2. Real-time Sync (Async)
+
+Triggered on Create, Update, Delete operations in the background.
+
+##### 3. Background Periodic Sync
+
+Runs every 60 seconds automatically, doing incremental sync.
+
+##### 4. Manual Sync
+
+Endpoint: `POST /sync`
diff --git a/typesense-node-prisma-full-text-search/package.json b/typesense-node-prisma-full-text-search/package.json
new file mode 100644
index 0000000..d7d7c65
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "typesense-node-prisma-search-app",
+ "version": "1.0.0",
+ "description": "A production-ready RESTful search API built with Node.js, Express, PostgreSQL, and Typesense.",
+ "main": "dist/server.js",
+ "scripts": {
+ "start": "node dist/server.js",
+ "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
+ "build": "tsc"
+ },
+ "dependencies": {
+ "@prisma/adapter-pg": "^7.8.0",
+ "@prisma/client": "^7.8.0",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "node-cron": "^3.0.3",
+ "pg": "^8.20.0",
+ "typesense": "^1.8.2"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.17",
+ "@types/express": "^4.17.21",
+ "@types/node": "^20.12.7",
+ "@types/node-cron": "^3.0.11",
+ "prisma": "^7.8.0",
+ "ts-node-dev": "^2.0.0",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/typesense-node-prisma-full-text-search/prisma.config.ts b/typesense-node-prisma-full-text-search/prisma.config.ts
new file mode 100644
index 0000000..831a20f
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/prisma.config.ts
@@ -0,0 +1,14 @@
+// This file was generated by Prisma, and assumes you have installed the following:
+// npm install --save-dev prisma dotenv
+import "dotenv/config";
+import { defineConfig } from "prisma/config";
+
+export default defineConfig({
+ schema: "prisma/schema.prisma",
+ migrations: {
+ path: "prisma/migrations",
+ },
+ datasource: {
+ url: process.env["DATABASE_URL"],
+ },
+});
diff --git a/typesense-node-prisma-full-text-search/prisma/migrations/20260502073537_init_books/migration.sql b/typesense-node-prisma-full-text-search/prisma/migrations/20260502073537_init_books/migration.sql
new file mode 100644
index 0000000..235560e
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/prisma/migrations/20260502073537_init_books/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "books" (
+ "id" SERIAL NOT NULL,
+ "title" VARCHAR(255) NOT NULL,
+ "authors" JSONB NOT NULL DEFAULT '[]',
+ "publication_year" INTEGER,
+ "average_rating" DECIMAL(3,2),
+ "image_url" VARCHAR(255),
+ "ratings_count" INTEGER,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "books_pkey" PRIMARY KEY ("id")
+);
diff --git a/typesense-node-prisma-full-text-search/prisma/migrations/migration_lock.toml b/typesense-node-prisma-full-text-search/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..044d57c
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/typesense-node-prisma-full-text-search/prisma/schema.prisma b/typesense-node-prisma-full-text-search/prisma/schema.prisma
new file mode 100644
index 0000000..9b5e64d
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/prisma/schema.prisma
@@ -0,0 +1,22 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+}
+
+model Book {
+ id Int @id @default(autoincrement())
+ title String @db.VarChar(255)
+ authors Json @default("[]")
+ publication_year Int?
+ average_rating Decimal? @db.Decimal(3, 2)
+ image_url String? @db.VarChar(255)
+ ratings_count Int?
+ created_at DateTime @default(now())
+ updated_at DateTime @updatedAt
+ deleted_at DateTime?
+
+ @@map("books")
+}
diff --git a/typesense-node-prisma-full-text-search/src/config/database.ts b/typesense-node-prisma-full-text-search/src/config/database.ts
new file mode 100644
index 0000000..3bc2212
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/config/database.ts
@@ -0,0 +1,13 @@
+import { PrismaClient } from '@prisma/client';
+import { PrismaPg } from '@prisma/adapter-pg';
+import { Pool } from 'pg';
+
+const connectionString = process.env.DATABASE_URL;
+
+const pool = new Pool({ connectionString });
+const adapter = new PrismaPg(pool);
+
+export const prisma = new PrismaClient({
+ adapter,
+ log: ['query', 'info', 'warn', 'error'],
+});
diff --git a/typesense-node-prisma-full-text-search/src/config/env.ts b/typesense-node-prisma-full-text-search/src/config/env.ts
new file mode 100644
index 0000000..ca37595
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/config/env.ts
@@ -0,0 +1,18 @@
+import dotenv from 'dotenv';
+
+dotenv.config();
+
+export const env = {
+ PORT: parseInt(process.env.PORT || '3000', 10),
+
+ DB_HOST: process.env.DB_HOST || 'localhost',
+ DB_USER: process.env.DB_USER || 'postgres',
+ DB_PASSWORD: process.env.DB_PASSWORD || 'password',
+ DB_NAME: process.env.DB_NAME || 'typesense_books',
+ DB_PORT: parseInt(process.env.DB_PORT || '5432', 10),
+
+ TYPESENSE_HOST: process.env.TYPESENSE_HOST || 'localhost',
+ TYPESENSE_PORT: parseInt(process.env.TYPESENSE_PORT || '8108', 10),
+ TYPESENSE_PROTOCOL: process.env.TYPESENSE_PROTOCOL || 'http',
+ TYPESENSE_API_KEY: process.env.TYPESENSE_API_KEY || 'xyz',
+};
diff --git a/typesense-node-prisma-full-text-search/src/routes/books.ts b/typesense-node-prisma-full-text-search/src/routes/books.ts
new file mode 100644
index 0000000..5c9702c
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/routes/books.ts
@@ -0,0 +1,155 @@
+import { Router, type Request, type Response } from 'express';
+import { prisma } from '../config/database';
+import type { Book } from '@prisma/client';
+import { typesenseClient } from '../search/client';
+import { BOOKS_COLLECTION_NAME } from '../search/collections';
+
+const router = Router();
+
+// Helper for real-time async sync
+const syncBookToTypesense = async (book: Book) => {
+ try {
+ // Prisma returns JSON as Prisma.JsonValue, we cast to array for typesense
+ const authorsArray = Array.isArray(book.authors) ? book.authors : [book.authors];
+
+ const document = {
+ id: book.id.toString(),
+ title: book.title,
+ authors: authorsArray as string[],
+ publication_year: book.publication_year || 0,
+ average_rating: book.average_rating ? Number(book.average_rating) : 0,
+ image_url: book.image_url || '',
+ ratings_count: book.ratings_count || 0,
+ };
+
+ console.log(`Syncing book ${book.id} to Typesense:`, document.title);
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().upsert(document);
+ console.log(`Successfully synced book ${book.id} to Typesense.`);
+ } catch (err) {
+ console.error(`Failed to sync book ${book.id} to Typesense:`, err);
+ throw err;
+ }
+};
+
+const deleteBookFromTypesense = async (id: number) => {
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents(id.toString()).delete();
+ } catch (err) {
+ console.error(`Failed to delete book ${id} from Typesense`, err);
+ }
+};
+
+// GET /books - Get all books with pagination
+router.get('/', async (req: Request, res: Response) => {
+ const page = parseInt(req.query.page as string || '1', 10);
+ const limit = parseInt(req.query.limit as string || '10', 10);
+ const offset = (page - 1) * limit;
+
+ try {
+ const [count, rows] = await Promise.all([
+ prisma.book.count({ where: { deleted_at: null } }),
+ prisma.book.findMany({
+ where: { deleted_at: null },
+ skip: offset,
+ take: limit,
+ orderBy: { id: 'asc' }
+ })
+ ]);
+
+ res.json({
+ total: count,
+ page,
+ limit,
+ data: rows
+ });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ error: 'Failed to fetch books' });
+ }
+});
+
+// GET /books/:id - Get a book
+router.get('/:id', async (req: Request, res: Response) => {
+ try {
+ const book = await prisma.book.findUnique({
+ where: {
+ id: parseInt(req.params.id),
+ deleted_at: null
+ }
+ });
+
+ if (!book) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+ res.json(book);
+ } catch (error) {
+ res.status(500).json({ error: 'Failed to fetch book' });
+ }
+});
+
+// POST /books - Create a book
+router.post('/', async (req: Request, res: Response) => {
+ try {
+ const book = await prisma.book.create({
+ data: req.body
+ });
+
+ // Real-time async sync
+ await syncBookToTypesense(book);
+
+ res.status(201).json(book);
+ } catch (error) {
+ res.status(400).json({ error: (error as Error).message });
+ }
+});
+
+// PUT /books/:id - Update a book
+router.put('/:id', async (req: Request, res: Response) => {
+ try {
+ const bookId = parseInt(req.params.id);
+ const existingBook = await prisma.book.findUnique({ where: { id: bookId, deleted_at: null } });
+
+ if (!existingBook) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+
+ const updatedBook = await prisma.book.update({
+ where: { id: bookId },
+ data: req.body
+ });
+
+ // Real-time async sync
+ await syncBookToTypesense(updatedBook);
+
+ res.json(updatedBook);
+ } catch (error) {
+ res.status(400).json({ error: (error as Error).message });
+ }
+});
+
+// DELETE /books/:id - Delete a book
+router.delete('/:id', async (req: Request, res: Response) => {
+ try {
+ const bookId = parseInt(req.params.id);
+ const existingBook = await prisma.book.findUnique({ where: { id: bookId, deleted_at: null } });
+
+ if (!existingBook) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+
+ // Soft delete
+ await prisma.book.update({
+ where: { id: bookId },
+ data: { deleted_at: new Date() }
+ });
+
+ // Real-time async sync
+ deleteBookFromTypesense(bookId);
+
+ res.status(204).send();
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+export default router;
diff --git a/typesense-node-prisma-full-text-search/src/routes/search.ts b/typesense-node-prisma-full-text-search/src/routes/search.ts
new file mode 100644
index 0000000..7daa5eb
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/routes/search.ts
@@ -0,0 +1,53 @@
+import { Router, type Request, type Response } from 'express';
+import { typesenseClient } from '../search/client';
+import { BOOKS_COLLECTION_NAME } from '../search/collections';
+import { runFullSync, lastSyncTime } from '../search/sync';
+import { getSyncStatus } from '../search/worker';
+
+const router = Router();
+
+// GET /search?q=
+router.get('/search', async (req: Request, res: Response) => {
+ const query = req.query.q as string || '';
+
+ try {
+ const searchResults = await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().search({
+ q: query,
+ query_by: 'title,authors',
+ });
+
+ res.json({
+ query,
+ found: searchResults.found,
+ results: searchResults.hits,
+ facet_counts: searchResults.facet_counts || [],
+ });
+ } catch (_error) {
+ res.status(500).json({ error: 'Failed to fetch books' });
+ }
+});
+
+// POST /sync - Trigger manual sync
+router.post('/sync', async (_req: Request, res: Response) => {
+ try {
+ // We run full sync here for manual trigger, but you could also run incremental
+ await runFullSync();
+
+ res.json({
+ message: 'Sync completed',
+ syncedAt: lastSyncTime.toISOString()
+ });
+ } catch (_error) {
+ res.status(500).json({ error: 'Failed to sync books' });
+ }
+});
+
+// GET /sync/status - Check sync status
+router.get('/sync/status', (_req: Request, res: Response) => {
+ res.json({
+ lastSyncTime: lastSyncTime.toISOString(),
+ syncWorkerRunning: getSyncStatus().syncWorkerRunning
+ });
+});
+
+export default router;
diff --git a/typesense-node-prisma-full-text-search/src/search/client.ts b/typesense-node-prisma-full-text-search/src/search/client.ts
new file mode 100644
index 0000000..97ca2dc
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/search/client.ts
@@ -0,0 +1,14 @@
+import { Client } from 'typesense';
+import { env } from '../config/env';
+
+export const typesenseClient = new Client({
+ nodes: [
+ {
+ host: env.TYPESENSE_HOST,
+ port: env.TYPESENSE_PORT,
+ protocol: env.TYPESENSE_PROTOCOL,
+ },
+ ],
+ apiKey: env.TYPESENSE_API_KEY,
+ connectionTimeoutSeconds: 5,
+});
diff --git a/typesense-node-prisma-full-text-search/src/search/collections.ts b/typesense-node-prisma-full-text-search/src/search/collections.ts
new file mode 100644
index 0000000..28d478d
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/search/collections.ts
@@ -0,0 +1,35 @@
+import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
+import { typesenseClient } from './client';
+
+export const BOOKS_COLLECTION_NAME = 'books';
+
+export const booksCollectionSchema: CollectionCreateSchema = {
+ name: BOOKS_COLLECTION_NAME,
+ fields: [
+ { name: 'id', type: 'string' },
+ { name: 'title', type: 'string' },
+ { name: 'authors', type: 'string[]', facet: true },
+ { name: 'publication_year', type: 'int32', facet: true, optional: true },
+ { name: 'average_rating', type: 'float', facet: true, optional: true },
+ { name: 'image_url', type: 'string', optional: true },
+ { name: 'ratings_count', type: 'int32', optional: true },
+ ],
+};
+
+export async function initializeTypesense(): Promise {
+ try {
+ const collections = await typesenseClient.collections().retrieve();
+ const collectionExists = collections.some((c) => c.name === BOOKS_COLLECTION_NAME);
+
+ if (!collectionExists) {
+ console.log(`Creating collection ${BOOKS_COLLECTION_NAME}...`);
+ await typesenseClient.collections().create(booksCollectionSchema);
+ console.log(`Collection ${BOOKS_COLLECTION_NAME} created successfully.`);
+ } else {
+ console.log(`Collection ${BOOKS_COLLECTION_NAME} already exists.`);
+ }
+ } catch (error) {
+ console.error('Error initializing Typesense collection:', error);
+ throw error;
+ }
+}
diff --git a/typesense-node-prisma-full-text-search/src/search/sync.ts b/typesense-node-prisma-full-text-search/src/search/sync.ts
new file mode 100644
index 0000000..d6173de
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/search/sync.ts
@@ -0,0 +1,184 @@
+import { prisma } from '../config/database';
+import { typesenseClient } from './client';
+import { BOOKS_COLLECTION_NAME } from './collections';
+import type { Book } from '@prisma/client';
+
+export let lastSyncTime: Date = new Date(0);
+
+const BATCH_SIZE = 1000;
+
+const mapBookToTypesense = (b: Book) => ({
+ id: b.id.toString(),
+ title: b.title,
+ authors: (Array.isArray(b.authors) ? b.authors : [b.authors]) as string[],
+ publication_year: b.publication_year || 0,
+ average_rating: b.average_rating ? Number(b.average_rating) : 0,
+ image_url: b.image_url || '',
+ ratings_count: b.ratings_count || 0,
+});
+
+export async function runFullSync() {
+ console.log('Running full sync...');
+ let lastId = 0;
+ let hasMore = true;
+ let totalProcessed = 0;
+
+ while (hasMore) {
+ let books: Book[];
+ try {
+ books = await prisma.book.findMany({
+ where: {
+ id: { gt: lastId },
+ deleted_at: null
+ },
+ take: BATCH_SIZE,
+ orderBy: { id: 'asc' }
+ });
+ } catch (err) {
+ console.error('Database error during full sync fetching:', err);
+ break; // Abort this sync run gracefully on DB failure
+ }
+
+ if (books.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ lastId = books[books.length - 1].id;
+
+ const documents = books.map(mapBookToTypesense);
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().import(documents, { action: 'upsert' });
+ totalProcessed += documents.length;
+ console.log(`Full sync: Processed ${totalProcessed} books.`);
+ } catch (err) {
+ console.error('Error importing documents during full sync', err);
+ break;
+ }
+ }
+
+ // Update lastSyncTime to now
+ lastSyncTime = new Date();
+ console.log('Full sync completed.');
+}
+
+export async function runIncrementalSync() {
+ console.log(`Running incremental sync since ${lastSyncTime.toISOString()}...`);
+
+ // 1. Process newly created or updated books in batches
+ let lastUpsertId = 0;
+ let hasMoreUpserts = true;
+ let totalUpserted = 0;
+
+ while (hasMoreUpserts) {
+ let updatedBooks: Book[];
+ try {
+ updatedBooks = await prisma.book.findMany({
+ where: {
+ updated_at: { gt: lastSyncTime },
+ deleted_at: null,
+ id: { gt: lastUpsertId }
+ },
+ take: BATCH_SIZE,
+ orderBy: { id: 'asc' }
+ });
+ } catch (err) {
+ console.error('Database error during incremental sync upsert fetching:', err);
+ break;
+ }
+
+ if (updatedBooks.length === 0) {
+ hasMoreUpserts = false;
+ break;
+ }
+
+ lastUpsertId = updatedBooks[updatedBooks.length - 1].id;
+ const documents = updatedBooks.map(mapBookToTypesense);
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().import(documents, { action: 'upsert' });
+ totalUpserted += documents.length;
+ } catch (err) {
+ console.error('Error upserting documents in incremental sync', err);
+ break;
+ }
+ }
+
+ if (totalUpserted > 0) {
+ console.log(`Incremental sync: Upserted ${totalUpserted} books.`);
+ }
+
+ // 2. Process soft-deleted books in batches
+ let lastDeleteId = 0;
+ let hasMoreDeletes = true;
+ let totalDeleted = 0;
+
+ while (hasMoreDeletes) {
+ let deletedBooks: Book[];
+ try {
+ deletedBooks = await prisma.book.findMany({
+ where: {
+ deleted_at: { gt: lastSyncTime },
+ id: { gt: lastDeleteId }
+ },
+ take: BATCH_SIZE,
+ orderBy: { id: 'asc' }
+ });
+ } catch (err) {
+ console.error('Database error during incremental sync delete fetching:', err);
+ break;
+ }
+
+ if (deletedBooks.length === 0) {
+ hasMoreDeletes = false;
+ break;
+ }
+
+ lastDeleteId = deletedBooks[deletedBooks.length - 1].id;
+ const ids = deletedBooks.map(b => b.id.toString());
+
+ try {
+ // Bulk delete in Typesense using filter_by
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().delete({
+ filter_by: `id:=[${ids.join(',')}]`
+ });
+ totalDeleted += deletedBooks.length;
+ } catch (err) {
+ console.error('Error deleting documents in incremental sync', err);
+ break;
+ }
+ }
+
+ if (totalDeleted > 0) {
+ console.log(`Incremental sync: Deleted ${totalDeleted} books from Typesense.`);
+ }
+
+ lastSyncTime = new Date();
+ console.log('Incremental sync completed.');
+}
+
+export async function determineAndRunStartupSync() {
+ try {
+ const searchStats = await typesenseClient.collections(BOOKS_COLLECTION_NAME).retrieve();
+ const docCount = searchStats.num_documents;
+
+ if (docCount === 0) {
+ // Empty Typesense collection, full sync
+ await runFullSync();
+ } else {
+ // Typesense has data, get latest updated_at from DB
+ const latestBook = await prisma.book.findFirst({
+ orderBy: { updated_at: 'desc' }
+ });
+
+ if (latestBook?.updated_at) {
+ lastSyncTime = latestBook.updated_at;
+ }
+
+ await runIncrementalSync();
+ }
+ } catch (error) {
+ console.error('Error during startup sync:', error);
+ }
+}
diff --git a/typesense-node-prisma-full-text-search/src/search/worker.ts b/typesense-node-prisma-full-text-search/src/search/worker.ts
new file mode 100644
index 0000000..775aa48
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/search/worker.ts
@@ -0,0 +1,31 @@
+import cron from 'node-cron';
+import { runIncrementalSync } from './sync';
+
+let isSyncRunning = false;
+
+export function startBackgroundSyncWorker() {
+ console.log('Starting background periodic sync worker (every 60s)...');
+
+ // Runs every minute
+ cron.schedule('* * * * *', async () => {
+ if (isSyncRunning) {
+ console.log('Sync already running, skipping this iteration.');
+ return;
+ }
+
+ isSyncRunning = true;
+ try {
+ await runIncrementalSync();
+ } catch (error) {
+ console.error('Error in background sync worker:', error);
+ } finally {
+ isSyncRunning = false;
+ }
+ });
+}
+
+export function getSyncStatus() {
+ return {
+ syncWorkerRunning: isSyncRunning,
+ };
+}
diff --git a/typesense-node-prisma-full-text-search/src/server.ts b/typesense-node-prisma-full-text-search/src/server.ts
new file mode 100644
index 0000000..51d78cb
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/src/server.ts
@@ -0,0 +1,49 @@
+import express from 'express';
+import cors from 'cors';
+import { env } from './config/env';
+import { prisma } from './config/database';
+import { initializeTypesense } from './search/collections';
+import { determineAndRunStartupSync } from './search/sync';
+import { startBackgroundSyncWorker } from './search/worker';
+
+import booksRouter from './routes/books';
+import searchRouter from './routes/search';
+
+const app = express();
+
+app.use(cors());
+app.use(express.json());
+
+// Routes
+app.use('/books', booksRouter);
+app.use('/', searchRouter);
+
+async function startServer() {
+ try {
+ // 1. Connect to PostgreSQL
+ console.log('Connecting to PostgreSQL database...');
+ await prisma.$connect();
+ console.log('Database connected.');
+
+ // 2. Initialize Typesense
+ console.log('Initializing Typesense...');
+ await initializeTypesense();
+
+ // 3. Run Startup Sync
+ console.log('Running startup sync...');
+ await determineAndRunStartupSync();
+
+ // 4. Start Background Worker
+ startBackgroundSyncWorker();
+
+ // 5. Start Express API
+ app.listen(env.PORT, () => {
+ console.log(`Server is running on http://localhost:${env.PORT}`);
+ });
+ } catch (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+}
+
+startServer();
diff --git a/typesense-node-prisma-full-text-search/tsconfig.json b/typesense-node-prisma-full-text-search/tsconfig.json
new file mode 100644
index 0000000..24cf495
--- /dev/null
+++ b/typesense-node-prisma-full-text-search/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "commonjs",
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/typesense-node-sequelize-full-text-search/.env.example b/typesense-node-sequelize-full-text-search/.env.example
new file mode 100644
index 0000000..9b6d483
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/.env.example
@@ -0,0 +1,15 @@
+# Server Configuration
+PORT=3000
+
+# Database Configuration
+DB_HOST=localhost
+DB_USER=postgres
+DB_PASSWORD=password
+DB_NAME=typesense_books
+DB_PORT=5432
+
+# Typesense Configuration
+TYPESENSE_HOST=localhost
+TYPESENSE_PORT=8108
+TYPESENSE_PROTOCOL=http
+TYPESENSE_API_KEY=xyz
diff --git a/typesense-node-sequelize-full-text-search/README.md b/typesense-node-sequelize-full-text-search/README.md
new file mode 100644
index 0000000..447e8e8
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/README.md
@@ -0,0 +1,212 @@
+# Node.js Express Full-Text Search with Typesense
+
+A production-ready RESTful search API built with Node.js, Express, PostgreSQL (Sequelize), and Typesense. Features full-text search, CRUD operations, real-time async indexing, and background sync workers.
+
+## Tech Stack
+
+- Node.js
+- Express
+- PostgreSQL with Sequelize
+- Typesense
+- TypeScript
+- Docker
+
+## Prerequisites
+
+- Node.js v18+ installed
+- Docker (for Typesense and PostgreSQL)
+- Basic knowledge of REST APIs and SQL
+
+## Quick Start
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/typesense/code-samples.git
+cd typesense-node-sequelize-search-app
+```
+
+### 2. Install dependencies
+
+```bash
+npm install
+```
+
+### 3. Start Typesense and PostgreSQL
+
+Run Typesense and PostgreSQL using Docker:
+
+```bash
+# Start Typesense (replace TYPESENSE_VERSION with the latest from https://typesense.org/docs/guide/install-typesense.html)
+docker run -d \
+ -p 8108:8108 \
+ -v typesense-data:/data \
+ typesense/typesense:27.1 \
+ --data-dir /data \
+ --api-key=xyz \
+ --enable-cors
+
+# Start PostgreSQL
+docker run -d \
+ -p 5432:5432 \
+ -e POSTGRES_USER=postgres \
+ -e POSTGRES_PASSWORD=password \
+ -e POSTGRES_DB=typesense_books \
+ -v postgres-data:/var/lib/postgresql/data \
+ postgres:15
+```
+
+### 4. Set up environment variables
+
+Create a `.env` file in the project root by copying `.env.example`:
+
+```bash
+cp .env.example .env
+```
+
+### 5. Project Structure
+
+```text
+├── src/
+│ ├── config/
+│ │ ├── database.ts # Sequelize configuration
+│ │ └── env.ts # Environment variable validation
+│ ├── models/
+│ │ └── Book.ts # Sequelize Book model
+│ ├── routes/
+│ │ ├── books.ts # CRUD endpoints for books
+│ │ └── search.ts # Search and sync endpoints
+│ ├── search/
+│ │ ├── client.ts # Typesense client initialization
+│ │ ├── collections.ts # Typesense collection schema
+│ │ ├── sync.ts # Sync logic (incremental, full, soft delete)
+│ │ └── worker.ts # Background sync worker
+│ └── server.ts # Main application entry point
+├── package.json
+├── tsconfig.json
+└── .env
+```
+
+### 6. Start the development server
+
+```bash
+npm run dev
+```
+
+The server will automatically restart when you make changes to any TypeScript file.
+
+Open [http://localhost:3000](http://localhost:3000) in your browser.
+
+### 7. API Endpoints
+
+#### Search
+
+```bash
+GET /search?q=
+```
+
+Example:
+
+```bash
+curl "http://localhost:3000/search?q=harry"
+```
+
+#### CRUD Operations
+
+**Create a book:**
+
+```bash
+POST /books
+Content-Type: application/json
+
+{
+ "title": "The Go Programming Language",
+ "authors": ["Alan Donovan", "Brian Kernighan"],
+ "publication_year": 2015,
+ "average_rating": 4.5,
+ "image_url": "https://example.com/image.jpg",
+ "ratings_count": 1000
+}
+```
+
+**Get a book:**
+
+```bash
+GET /books/:id
+```
+
+**Get all books (with pagination):**
+
+```bash
+GET /books?page=1&limit=10
+```
+
+**Update a book:**
+
+```bash
+PUT /books/:id
+Content-Type: application/json
+
+{
+ "title": "Updated Title",
+ "authors": ["Author Name"],
+ "publication_year": 2024,
+ "average_rating": 4.8,
+ "image_url": "https://example.com/updated.jpg",
+ "ratings_count": 1500
+}
+```
+
+**Delete a book (soft delete):**
+
+```bash
+DELETE /books/:id
+```
+
+#### Sync Operations
+
+**Trigger manual sync:**
+
+```bash
+POST /sync
+```
+
+**Check sync status:**
+
+```bash
+GET /sync/status
+```
+
+### 8. How It Works
+
+#### Architecture
+
+```plaintext
+User Request
+ ↓
+Express API (CRUD)
+ ↓
+PostgreSQL (Source of Truth)
+ ↓
+Async Sync → Typesense (Search Index)
+ ↑
+Background Worker (Every 60s)
+```
+
+#### Sync Strategies
+
+##### 1. Startup Sync (Smart)
+
+On every server start, the sync worker checks whether the Typesense collection already has documents. If empty, it seeds `lastSyncTime` to zero and runs a full sync. If it has data, it runs an incremental sync since `MAX(updated_at)` of PostgreSQL books table.
+
+##### 2. Real-time Sync (Async)
+
+Triggered on Create, Update, Delete operations in the background.
+
+##### 3. Background Periodic Sync
+
+Runs every 60 seconds automatically, doing incremental sync.
+
+##### 4. Manual Sync
+
+Endpoint: `POST /sync`
diff --git a/typesense-node-sequelize-full-text-search/package.json b/typesense-node-sequelize-full-text-search/package.json
new file mode 100644
index 0000000..73d21b2
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "typesense-node-sequelize-search-app",
+ "version": "1.0.0",
+ "description": "A production-ready RESTful search API built with Node.js, Express, PostgreSQL, and Typesense.",
+ "main": "dist/server.js",
+ "scripts": {
+ "start": "node dist/server.js",
+ "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
+ "build": "tsc"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "node-cron": "^3.0.3",
+ "pg": "^8.11.5",
+ "pg-hstore": "^2.3.4",
+ "sequelize": "^6.37.3",
+ "typesense": "^1.8.2"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.17",
+ "@types/express": "^4.17.21",
+ "@types/node": "^20.12.7",
+ "@types/node-cron": "^3.0.11",
+ "ts-node-dev": "^2.0.0",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/typesense-node-sequelize-full-text-search/src/config/database.ts b/typesense-node-sequelize-full-text-search/src/config/database.ts
new file mode 100644
index 0000000..1f22f20
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/config/database.ts
@@ -0,0 +1,15 @@
+import { Sequelize } from 'sequelize';
+import { env } from './env';
+
+export const sequelize = new Sequelize(env.DB_NAME, env.DB_USER, env.DB_PASSWORD, {
+ host: env.DB_HOST,
+ port: env.DB_PORT,
+ dialect: 'postgres',
+ logging: console.log,
+ pool: {
+ max: 5,
+ min: 0,
+ acquire: 30000,
+ idle: 10000
+ }
+});
diff --git a/typesense-node-sequelize-full-text-search/src/config/env.ts b/typesense-node-sequelize-full-text-search/src/config/env.ts
new file mode 100644
index 0000000..ca37595
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/config/env.ts
@@ -0,0 +1,18 @@
+import dotenv from 'dotenv';
+
+dotenv.config();
+
+export const env = {
+ PORT: parseInt(process.env.PORT || '3000', 10),
+
+ DB_HOST: process.env.DB_HOST || 'localhost',
+ DB_USER: process.env.DB_USER || 'postgres',
+ DB_PASSWORD: process.env.DB_PASSWORD || 'password',
+ DB_NAME: process.env.DB_NAME || 'typesense_books',
+ DB_PORT: parseInt(process.env.DB_PORT || '5432', 10),
+
+ TYPESENSE_HOST: process.env.TYPESENSE_HOST || 'localhost',
+ TYPESENSE_PORT: parseInt(process.env.TYPESENSE_PORT || '8108', 10),
+ TYPESENSE_PROTOCOL: process.env.TYPESENSE_PROTOCOL || 'http',
+ TYPESENSE_API_KEY: process.env.TYPESENSE_API_KEY || 'xyz',
+};
diff --git a/typesense-node-sequelize-full-text-search/src/models/Book.ts b/typesense-node-sequelize-full-text-search/src/models/Book.ts
new file mode 100644
index 0000000..4ae8f54
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/models/Book.ts
@@ -0,0 +1,79 @@
+import { Model, DataTypes, type Optional } from 'sequelize';
+import { sequelize } from '../config/database';
+
+export interface BookAttributes {
+ id: number;
+ title: string;
+ authors: string[];
+ publication_year: number;
+ average_rating: number;
+ image_url: string;
+ ratings_count: number;
+ created_at?: Date;
+ updated_at?: Date;
+ deleted_at?: Date | null;
+}
+
+export interface BookCreationAttributes extends Optional {}
+
+export class Book extends Model implements BookAttributes {
+ declare id: number;
+ declare title: string;
+ declare authors: string[];
+ declare publication_year: number;
+ declare average_rating: number;
+ declare image_url: string;
+ declare ratings_count: number;
+
+ declare readonly created_at: Date;
+ declare readonly updated_at: Date;
+ declare readonly deleted_at: Date | null;
+}
+
+Book.init(
+ {
+ id: {
+ type: DataTypes.INTEGER,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ title: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ },
+ authors: {
+ type: DataTypes.JSONB,
+ allowNull: false,
+ defaultValue: [],
+ },
+ publication_year: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ },
+ average_rating: {
+ type: DataTypes.DECIMAL(3, 2),
+ allowNull: true,
+ get() {
+ const value = this.getDataValue('average_rating');
+ return value === null ? null : parseFloat(value as unknown as string);
+ }
+ },
+ image_url: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ },
+ ratings_count: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ },
+ },
+ {
+ sequelize,
+ tableName: 'books',
+ timestamps: true,
+ paranoid: true, // Enables soft deletes (deletedAt)
+ createdAt: 'created_at',
+ updatedAt: 'updated_at',
+ deletedAt: 'deleted_at',
+ }
+);
diff --git a/typesense-node-sequelize-full-text-search/src/routes/books.ts b/typesense-node-sequelize-full-text-search/src/routes/books.ts
new file mode 100644
index 0000000..17794c0
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/routes/books.ts
@@ -0,0 +1,127 @@
+import { Router, type Request, type Response } from 'express';
+import { Book } from '../models/Book';
+import { typesenseClient } from '../search/client';
+import { BOOKS_COLLECTION_NAME } from '../search/collections';
+
+const router = Router();
+
+// Helper for real-time async sync
+const syncBookToTypesense = async (book: Book) => {
+ try {
+ const document = {
+ id: book.id.toString(),
+ title: book.title,
+ authors: Array.isArray(book.authors) ? book.authors : [book.authors],
+ publication_year: book.publication_year || 0,
+ average_rating: typeof book.average_rating === 'number' ? book.average_rating : parseFloat(book.average_rating || '0'),
+ image_url: book.image_url || '',
+ ratings_count: book.ratings_count || 0,
+ };
+
+ console.log(`Syncing book ${book.id} to Typesense:`, document.title);
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().upsert(document);
+ console.log(`Successfully synced book ${book.id} to Typesense.`);
+ } catch (err) {
+ console.error(`Failed to sync book ${book.id} to Typesense:`, err);
+ throw err;
+ }
+};
+
+const deleteBookFromTypesense = async (id: number) => {
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents(id.toString()).delete();
+ } catch (err) {
+ console.error(`Failed to delete book ${id} from Typesense`, err);
+ }
+};
+
+// GET /books - Get all books with pagination
+router.get('/', async (req: Request, res: Response) => {
+ const page = parseInt(req.query.page as string || '1', 10);
+ const limit = parseInt(req.query.limit as string || '10', 10);
+ const offset = (page - 1) * limit;
+
+ try {
+ const { count, rows } = await Book.findAndCountAll({
+ limit,
+ offset,
+ order: [['id', 'ASC']]
+ });
+
+ res.json({
+ total: count,
+ page,
+ limit,
+ data: rows
+ });
+ } catch (_error) {
+ res.status(500).json({ error: 'Failed to fetch books' });
+ }
+});
+
+// GET /books/:id - Get a book
+router.get('/:id', async (req: Request, res: Response) => {
+ try {
+ const book = await Book.findByPk(req.params.id);
+ if (!book) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+ res.json(book);
+ } catch (_error) {
+ res.status(500).json({ error: 'Failed to fetch book' });
+ }
+});
+
+// POST /books - Create a book
+router.post('/', async (req: Request, res: Response) => {
+ try {
+ const book = await Book.create(req.body);
+
+ // Real-time async sync (now awaited to ensure consistency in tests)
+ await syncBookToTypesense(book);
+
+ res.status(201).json(book);
+ } catch (error) {
+ res.status(400).json({ error: (error as Error).message });
+ }
+});
+
+// PUT /books/:id - Update a book
+router.put('/:id', async (req: Request, res: Response) => {
+ try {
+ const book = await Book.findByPk(req.params.id);
+ if (!book) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+
+ await book.update(req.body);
+
+ // Real-time async sync (now awaited to ensure consistency in tests)
+ await syncBookToTypesense(book);
+
+ res.json(book);
+ } catch (error) {
+ res.status(400).json({ error: (error as Error).message });
+ }
+});
+
+// DELETE /books/:id - Delete a book
+router.delete('/:id', async (req: Request, res: Response) => {
+ try {
+ const book = await Book.findByPk(req.params.id);
+ if (!book) {
+ return res.status(404).json({ error: 'Book not found' });
+ }
+
+ await book.destroy();
+
+ // Real-time async sync
+ deleteBookFromTypesense(book.id);
+
+ res.status(204).send();
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+export default router;
diff --git a/typesense-node-sequelize-full-text-search/src/routes/search.ts b/typesense-node-sequelize-full-text-search/src/routes/search.ts
new file mode 100644
index 0000000..7daa5eb
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/routes/search.ts
@@ -0,0 +1,53 @@
+import { Router, type Request, type Response } from 'express';
+import { typesenseClient } from '../search/client';
+import { BOOKS_COLLECTION_NAME } from '../search/collections';
+import { runFullSync, lastSyncTime } from '../search/sync';
+import { getSyncStatus } from '../search/worker';
+
+const router = Router();
+
+// GET /search?q=
+router.get('/search', async (req: Request, res: Response) => {
+ const query = req.query.q as string || '';
+
+ try {
+ const searchResults = await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().search({
+ q: query,
+ query_by: 'title,authors',
+ });
+
+ res.json({
+ query,
+ found: searchResults.found,
+ results: searchResults.hits,
+ facet_counts: searchResults.facet_counts || [],
+ });
+ } catch (_error) {
+ res.status(500).json({ error: 'Failed to fetch books' });
+ }
+});
+
+// POST /sync - Trigger manual sync
+router.post('/sync', async (_req: Request, res: Response) => {
+ try {
+ // We run full sync here for manual trigger, but you could also run incremental
+ await runFullSync();
+
+ res.json({
+ message: 'Sync completed',
+ syncedAt: lastSyncTime.toISOString()
+ });
+ } catch (_error) {
+ res.status(500).json({ error: 'Failed to sync books' });
+ }
+});
+
+// GET /sync/status - Check sync status
+router.get('/sync/status', (_req: Request, res: Response) => {
+ res.json({
+ lastSyncTime: lastSyncTime.toISOString(),
+ syncWorkerRunning: getSyncStatus().syncWorkerRunning
+ });
+});
+
+export default router;
diff --git a/typesense-node-sequelize-full-text-search/src/search/client.ts b/typesense-node-sequelize-full-text-search/src/search/client.ts
new file mode 100644
index 0000000..97ca2dc
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/search/client.ts
@@ -0,0 +1,14 @@
+import { Client } from 'typesense';
+import { env } from '../config/env';
+
+export const typesenseClient = new Client({
+ nodes: [
+ {
+ host: env.TYPESENSE_HOST,
+ port: env.TYPESENSE_PORT,
+ protocol: env.TYPESENSE_PROTOCOL,
+ },
+ ],
+ apiKey: env.TYPESENSE_API_KEY,
+ connectionTimeoutSeconds: 5,
+});
diff --git a/typesense-node-sequelize-full-text-search/src/search/collections.ts b/typesense-node-sequelize-full-text-search/src/search/collections.ts
new file mode 100644
index 0000000..28d478d
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/search/collections.ts
@@ -0,0 +1,35 @@
+import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
+import { typesenseClient } from './client';
+
+export const BOOKS_COLLECTION_NAME = 'books';
+
+export const booksCollectionSchema: CollectionCreateSchema = {
+ name: BOOKS_COLLECTION_NAME,
+ fields: [
+ { name: 'id', type: 'string' },
+ { name: 'title', type: 'string' },
+ { name: 'authors', type: 'string[]', facet: true },
+ { name: 'publication_year', type: 'int32', facet: true, optional: true },
+ { name: 'average_rating', type: 'float', facet: true, optional: true },
+ { name: 'image_url', type: 'string', optional: true },
+ { name: 'ratings_count', type: 'int32', optional: true },
+ ],
+};
+
+export async function initializeTypesense(): Promise {
+ try {
+ const collections = await typesenseClient.collections().retrieve();
+ const collectionExists = collections.some((c) => c.name === BOOKS_COLLECTION_NAME);
+
+ if (!collectionExists) {
+ console.log(`Creating collection ${BOOKS_COLLECTION_NAME}...`);
+ await typesenseClient.collections().create(booksCollectionSchema);
+ console.log(`Collection ${BOOKS_COLLECTION_NAME} created successfully.`);
+ } else {
+ console.log(`Collection ${BOOKS_COLLECTION_NAME} already exists.`);
+ }
+ } catch (error) {
+ console.error('Error initializing Typesense collection:', error);
+ throw error;
+ }
+}
diff --git a/typesense-node-sequelize-full-text-search/src/search/sync.ts b/typesense-node-sequelize-full-text-search/src/search/sync.ts
new file mode 100644
index 0000000..3a809b9
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/search/sync.ts
@@ -0,0 +1,148 @@
+import { Op } from 'sequelize';
+import { Book } from '../models/Book';
+import { typesenseClient } from './client';
+import { BOOKS_COLLECTION_NAME } from './collections';
+
+export let lastSyncTime: Date = new Date(0);
+
+const BATCH_SIZE = 1000;
+
+export async function runFullSync() {
+ console.log('Running full sync...');
+ let lastId = 0;
+ let hasMore = true;
+ let totalProcessed = 0;
+
+ while (hasMore) {
+ let books: Book[];
+ try {
+ books = await Book.findAll({
+ where: { id: { [Op.gt]: lastId } },
+ limit: BATCH_SIZE,
+ order: [['id', 'ASC']],
+ paranoid: true, // Only fetch active records
+ });
+ } catch (err) {
+ console.error('Database error during full sync fetching:', err);
+ break; // Abort this sync run gracefully on DB failure
+ }
+
+ if (books.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ lastId = books[books.length - 1].id;
+
+ const documents = books.map((b) => ({
+ id: b.id.toString(),
+ title: b.title,
+ authors: b.authors,
+ publication_year: b.publication_year || 0,
+ average_rating: b.average_rating || 0.0,
+ image_url: b.image_url || '',
+ ratings_count: b.ratings_count || 0,
+ }));
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().import(documents, { action: 'upsert' });
+ totalProcessed += documents.length;
+ console.log(`Full sync: Processed ${totalProcessed} books.`);
+ } catch (err) {
+ console.error('Error importing documents during full sync', err);
+ // We can choose to break or continue here; breaking is safer on Typesense errors
+ break;
+ }
+ }
+
+ // Update lastSyncTime to now
+ lastSyncTime = new Date();
+ console.log('Full sync completed.');
+}
+
+export async function runIncrementalSync() {
+ console.log(`Running incremental sync since ${lastSyncTime.toISOString()}...`);
+
+ // 1. Find newly created or updated books
+ const updatedBooks = await Book.findAll({
+ where: {
+ updated_at: {
+ [Op.gt]: lastSyncTime,
+ },
+ },
+ paranoid: true, // Only active
+ });
+
+ if (updatedBooks.length > 0) {
+ const documents = updatedBooks.map((b) => ({
+ id: b.id.toString(),
+ title: b.title,
+ authors: b.authors,
+ publication_year: b.publication_year || 0,
+ average_rating: b.average_rating || 0.0,
+ image_url: b.image_url || '',
+ ratings_count: b.ratings_count || 0,
+ }));
+
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents().import(documents, { action: 'upsert' });
+ console.log(`Incremental sync: Upserted ${documents.length} books.`);
+ } catch (err) {
+ console.error('Error upserting documents in incremental sync', err);
+ }
+ }
+
+ // 2. Find soft-deleted books
+ const deletedBooks = await Book.findAll({
+ where: {
+ deleted_at: {
+ [Op.gt]: lastSyncTime,
+ },
+ },
+ paranoid: false, // Include soft-deleted
+ });
+
+ if (deletedBooks.length > 0) {
+ for (const book of deletedBooks) {
+ try {
+ await typesenseClient.collections(BOOKS_COLLECTION_NAME).documents(book.id.toString()).delete();
+ console.log(`Incremental sync: Deleted book ${book.id} from Typesense.`);
+ } catch (err) {
+ // Typesense might return 404 if document doesn't exist, which is fine
+ const error = err as { httpStatus?: number };
+ if (error.httpStatus !== 404) {
+ console.error(`Error deleting book ${book.id} from Typesense`, err);
+ }
+ }
+ }
+ }
+
+ lastSyncTime = new Date();
+ console.log('Incremental sync completed.');
+}
+
+export async function determineAndRunStartupSync() {
+ try {
+ const searchStats = await typesenseClient.collections(BOOKS_COLLECTION_NAME).retrieve();
+ const docCount = searchStats.num_documents;
+
+ if (docCount === 0) {
+ // Empty Typesense collection, full sync
+ await runFullSync();
+ } else {
+ // Typesense has data, get latest updated_at from DB
+ const latestBook = await Book.findOne({
+ order: [['updated_at', 'DESC']],
+ paranoid: false, // Check across all records
+ });
+
+ if (latestBook?.updated_at) {
+ lastSyncTime = latestBook.updated_at;
+ }
+
+ await runIncrementalSync();
+ }
+ } catch (error) {
+ console.error('Error during startup sync:', error);
+ }
+}
diff --git a/typesense-node-sequelize-full-text-search/src/search/worker.ts b/typesense-node-sequelize-full-text-search/src/search/worker.ts
new file mode 100644
index 0000000..775aa48
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/search/worker.ts
@@ -0,0 +1,31 @@
+import cron from 'node-cron';
+import { runIncrementalSync } from './sync';
+
+let isSyncRunning = false;
+
+export function startBackgroundSyncWorker() {
+ console.log('Starting background periodic sync worker (every 60s)...');
+
+ // Runs every minute
+ cron.schedule('* * * * *', async () => {
+ if (isSyncRunning) {
+ console.log('Sync already running, skipping this iteration.');
+ return;
+ }
+
+ isSyncRunning = true;
+ try {
+ await runIncrementalSync();
+ } catch (error) {
+ console.error('Error in background sync worker:', error);
+ } finally {
+ isSyncRunning = false;
+ }
+ });
+}
+
+export function getSyncStatus() {
+ return {
+ syncWorkerRunning: isSyncRunning,
+ };
+}
diff --git a/typesense-node-sequelize-full-text-search/src/server.ts b/typesense-node-sequelize-full-text-search/src/server.ts
new file mode 100644
index 0000000..ef8e23c
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/src/server.ts
@@ -0,0 +1,51 @@
+import express from 'express';
+import cors from 'cors';
+import { env } from './config/env';
+import { sequelize } from './config/database';
+import { initializeTypesense } from './search/collections';
+import { determineAndRunStartupSync } from './search/sync';
+import { startBackgroundSyncWorker } from './search/worker';
+
+import booksRouter from './routes/books';
+import searchRouter from './routes/search';
+
+const app = express();
+
+app.use(cors());
+app.use(express.json());
+
+// Routes
+app.use('/books', booksRouter);
+app.use('/', searchRouter);
+
+async function startServer() {
+ try {
+ // 1. Connect to PostgreSQL
+ console.log('Connecting to PostgreSQL database...');
+ await sequelize.authenticate();
+ // In production, use migrations instead of sync()
+ await sequelize.sync();
+ console.log('Database connected and models synced.');
+
+ // 2. Initialize Typesense
+ console.log('Initializing Typesense...');
+ await initializeTypesense();
+
+ // 3. Run Startup Sync
+ console.log('Running startup sync...');
+ await determineAndRunStartupSync();
+
+ // 4. Start Background Worker
+ startBackgroundSyncWorker();
+
+ // 5. Start Express API
+ app.listen(env.PORT, () => {
+ console.log(`Server is running on http://localhost:${env.PORT}`);
+ });
+ } catch (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+}
+
+startServer();
diff --git a/typesense-node-sequelize-full-text-search/tsconfig.json b/typesense-node-sequelize-full-text-search/tsconfig.json
new file mode 100644
index 0000000..24cf495
--- /dev/null
+++ b/typesense-node-sequelize-full-text-search/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "commonjs",
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/typesense-springboot-full-text-search/.env.example b/typesense-springboot-full-text-search/.env.example
new file mode 100644
index 0000000..0f84697
--- /dev/null
+++ b/typesense-springboot-full-text-search/.env.example
@@ -0,0 +1,26 @@
+# Server Configuration
+PORT=4000
+
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432
+DB_NAME=typesense_books
+DB_USER=xxx
+DB_PASSWORD=xxx
+
+# JPA / Hibernate
+HIBERNATE_DDL_AUTO=update
+
+# Typesense Configuration
+TYPESENSE_HOST=localhost
+TYPESENSE_PORT=8108
+TYPESENSE_PROTOCOL=http
+TYPESENSE_API_KEY=xyz
+TYPESENSE_COLLECTION_NAME=books
+TYPESENSE_CONNECTION_TIMEOUT=2
+
+# Sync Configuration
+TYPESENSE_SYNC_INTERVAL=60000
+TYPESENSE_SYNC_BATCH_SIZE=1000
+TYPESENSE_SYNC_PAGE_SIZE=1000
+TYPESENSE_SYNC_ENABLE_SOFT_DELETE=true
diff --git a/typesense-springboot-full-text-search/.gitattributes b/typesense-springboot-full-text-search/.gitattributes
new file mode 100644
index 0000000..3b41682
--- /dev/null
+++ b/typesense-springboot-full-text-search/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/typesense-springboot-full-text-search/.gitignore b/typesense-springboot-full-text-search/.gitignore
new file mode 100644
index 0000000..667aaef
--- /dev/null
+++ b/typesense-springboot-full-text-search/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/typesense-springboot-full-text-search/.mvn/wrapper/maven-wrapper.properties b/typesense-springboot-full-text-search/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..c595b00
--- /dev/null
+++ b/typesense-springboot-full-text-search/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
diff --git a/typesense-springboot-full-text-search/README.md b/typesense-springboot-full-text-search/README.md
new file mode 100644
index 0000000..13aa5d2
--- /dev/null
+++ b/typesense-springboot-full-text-search/README.md
@@ -0,0 +1,241 @@
+# Spring Boot Full-Text Search with Typesense
+
+A production-ready RESTful search API built with Spring Boot, PostgreSQL, and Typesense. Features full-text search, CRUD operations, real-time async indexing, and scheduled background sync.
+
+## Tech Stack
+
+- Java 17+
+- Spring Boot 4.x
+- PostgreSQL with Spring Data JPA
+- Typesense
+- Docker
+
+## Prerequisites
+
+- Java 17+ installed
+- Maven 3.9+
+- Docker (for Typesense and PostgreSQL)
+
+## Quick Start
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/typesense/code-samples.git
+cd typesense-springboot-full-text-search
+```
+
+### 2. Start Typesense and PostgreSQL
+
+```bash
+# Start Typesense
+docker run -d \
+ -p 8108:8108 \
+ -v typesense-data:/data \
+ typesense/typesense:latest \
+ --data-dir /data \
+ --api-key=xyz \
+ --enable-cors
+
+# Start PostgreSQL
+docker run -d \
+ -p 5432:5432 \
+ -e POSTGRES_USER=postgres \
+ -e POSTGRES_PASSWORD=password \
+ -e POSTGRES_DB=typesense_books \
+ -v postgres-data:/var/lib/postgresql/data \
+ postgres:15
+```
+
+### 3. Set up environment variables
+
+Copy the example file and adjust as needed:
+
+```bash
+cp .env.example .env
+```
+
+Or export the variables directly:
+
+```bash
+export DB_HOST=localhost
+export DB_PORT=5432
+export DB_USER=postgres
+export DB_PASSWORD=password
+export DB_NAME=typesense_books
+export TYPESENSE_HOST=localhost
+export TYPESENSE_PORT=8108
+export TYPESENSE_PROTOCOL=http
+export TYPESENSE_API_KEY=xyz
+```
+
+### 4. Project Structure
+
+```text
+src/main/java/org/typesense/full_text_search/
+├── FullTextSearchApplication.java # Entry point (@EnableScheduling, @EnableAsync)
+├── config/
+│ ├── TypesenseConfig.java # Typesense client bean
+│ └── AsyncConfig.java # Thread pool for async Typesense operations
+├── model/
+│ └── Book.java # JPA entity with soft delete support
+├── repository/
+│ └── BookRepository.java # Spring Data JPA repository
+├── service/
+│ ├── BookService.java # Book CRUD operations
+│ └── TypesenseService.java # Typesense search, sync, collection management
+├── scheduler/
+│ └── TypesenseSyncScheduler.java # @Scheduled periodic sync worker
+└── controller/
+ ├── BookController.java # CRUD endpoints for books
+ ├── SearchController.java # Search endpoint
+ └── SyncController.java # Manual sync + status endpoints
+```
+
+### 5. Start the development server
+
+```bash
+./mvnw spring-boot:run
+```
+
+Open [http://localhost:4000](http://localhost:4000).
+
+### 6. API Endpoints
+
+#### Search
+
+```bash
+GET /search?q=
+```
+
+Example:
+
+```bash
+curl "http://localhost:4000/search?q=harry"
+```
+
+#### CRUD Operations
+
+**Create a book:**
+
+```bash
+curl -X POST http://localhost:4000/books \
+ -H "Content-Type: application/json" \
+ -d '{
+ "title": "The Go Programming Language",
+ "authors": ["Alan Donovan", "Brian Kernighan"],
+ "publicationYear": 2015,
+ "averageRating": 4.5,
+ "imageUrl": "https://example.com/image.jpg",
+ "ratingsCount": 1000
+ }'
+```
+
+**Get a book:**
+
+```bash
+GET /books/:id
+```
+
+**Get all books (paginated):**
+
+```bash
+GET /books?page=1&page_size=100
+```
+
+**Update a book:**
+
+```bash
+PUT /books/:id
+```
+
+**Delete a book (soft delete):**
+
+```bash
+DELETE /books/:id
+```
+
+#### Sync Operations
+
+**Trigger manual sync:**
+
+```bash
+POST /sync
+```
+
+**Check sync status:**
+
+```bash
+GET /sync/status
+```
+
+### 7. How It Works
+
+#### Architecture
+
+```plaintext
+User Request
+ ↓
+Spring Boot API (CRUD)
+ ↓
+PostgreSQL (Source of Truth)
+ ↓
+Async Sync → Typesense (Search Index)
+ ↑
+@Scheduled Worker (Every 60s)
+```
+
+#### Sync Strategies
+
+##### 1. Startup Sync (Smart)
+
+On application startup (`ApplicationReadyEvent`), the scheduler checks whether the Typesense collection already has documents:
+
+- **Typesense is empty**: Seeds `lastSyncTime` to epoch and runs a full sync.
+- **Typesense already has data**: Seeds `lastSyncTime` from `MAX(updated_at)` of the books table, then runs an incremental sync.
+
+##### 2. Real-time Sync (Async)
+
+- Triggered on: Create, Update, Delete operations
+- Non-blocking: API responds immediately
+- Runs in a dedicated thread pool (`typesenseAsyncExecutor`)
+- If it fails, the background worker catches it within 60 seconds
+
+##### 3. Background Periodic Sync (`@Scheduled`)
+
+- Runs every 60 seconds (configurable via `typesense.sync.interval-ms`)
+- Incremental: Only syncs books with `updated_at > lastSyncTime`
+- Handles soft deletes: Removes deleted books from Typesense
+- Uses upsert for both inserts and updates
+
+##### 4. Manual Sync
+
+- Endpoint: `POST /sync`
+- On-demand sync trigger
+
+#### Configuration
+
+All sync parameters are configurable in `application.properties`:
+
+```properties
+typesense.sync.interval-ms=60000
+typesense.sync.batch-size=1000
+typesense.sync.page-size=1000
+typesense.sync.enable-soft-delete=true
+```
+
+### 8. Deployment
+
+**Environment Variables for Production:**
+
+```env
+DB_HOST=your-postgres-host.com
+DB_USER=your-db-user
+DB_PASSWORD=your-secure-password
+DB_NAME=typesense_books
+DB_PORT=5432
+TYPESENSE_HOST=xxx.typesense.net
+TYPESENSE_PORT=443
+TYPESENSE_PROTOCOL=https
+TYPESENSE_API_KEY=your-production-api-key
+```
diff --git a/typesense-springboot-full-text-search/mvnw b/typesense-springboot-full-text-search/mvnw
new file mode 100755
index 0000000..bd8896b
--- /dev/null
+++ b/typesense-springboot-full-text-search/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/typesense-springboot-full-text-search/mvnw.cmd b/typesense-springboot-full-text-search/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/typesense-springboot-full-text-search/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/typesense-springboot-full-text-search/pom.xml b/typesense-springboot-full-text-search/pom.xml
new file mode 100644
index 0000000..98c1710
--- /dev/null
+++ b/typesense-springboot-full-text-search/pom.xml
@@ -0,0 +1,127 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.5
+
+
+ org.typesense
+ full-text-search
+ 0.0.1-SNAPSHOT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 17
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+
+ org.typesense
+ typesense-java
+ 1.3.0
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ com.h2database
+ h2
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.github.cdimascio
+ dotenv-java
+ 3.0.0
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ default-compile
+ compile
+
+ compile
+
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ default-testCompile
+ test-compile
+
+ testCompile
+
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
+
+
diff --git a/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/FullTextSearchApplication.java b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/FullTextSearchApplication.java
new file mode 100644
index 0000000..1a63fda
--- /dev/null
+++ b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/FullTextSearchApplication.java
@@ -0,0 +1,25 @@
+package org.typesense.full_text_search;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.typesense.full_text_search.config.DatabaseInitializer;
+
+import io.github.cdimascio.dotenv.Dotenv;
+
+@SpringBootApplication
+@EnableScheduling
+@EnableAsync
+public class FullTextSearchApplication {
+
+ public static void main(String[] args) {
+ // Load .env variables into system properties for Spring Boot to use
+ Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load();
+ dotenv.entries().forEach(entry -> System.setProperty(entry.getKey(), entry.getValue()));
+
+ DatabaseInitializer.ensureDatabaseExists();
+ SpringApplication.run(FullTextSearchApplication.class, args);
+ }
+
+}
diff --git a/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/AsyncConfig.java b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/AsyncConfig.java
new file mode 100644
index 0000000..e6b8ee7
--- /dev/null
+++ b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/AsyncConfig.java
@@ -0,0 +1,22 @@
+package org.typesense.full_text_search.config;
+
+import java.util.concurrent.Executor;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+@Configuration
+public class AsyncConfig {
+
+ @Bean(name = "typesenseAsyncExecutor")
+ public Executor typesenseAsyncExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(4);
+ executor.setQueueCapacity(100);
+ executor.setThreadNamePrefix("typesense-async-");
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/DatabaseInitializer.java b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/DatabaseInitializer.java
new file mode 100644
index 0000000..6a571cf
--- /dev/null
+++ b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/DatabaseInitializer.java
@@ -0,0 +1,55 @@
+package org.typesense.full_text_search.config;
+
+import java.io.InputStream;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Properties;
+
+public class DatabaseInitializer {
+
+ private DatabaseInitializer() {
+ }
+
+ public static void ensureDatabaseExists() {
+ Properties props = new Properties();
+ try (InputStream is = DatabaseInitializer.class.getClassLoader()
+ .getResourceAsStream("application.properties")) {
+ if (is != null) {
+ props.load(is);
+ }
+ } catch (Exception e) {
+ System.err.println("Could not load application.properties: " + e.getMessage());
+ return;
+ }
+
+ String url = props.getProperty("spring.datasource.url");
+ String username = props.getProperty("spring.datasource.username");
+ String password = props.getProperty("spring.datasource.password");
+
+ if (url == null || !url.contains("postgresql")) return;
+
+ String dbName = extractDatabaseName(url);
+ String baseUrl = url.substring(0, url.lastIndexOf('/')) + "/postgres";
+
+ try (Connection conn = DriverManager.getConnection(baseUrl, username, password);
+ Statement stmt = conn.createStatement()) {
+
+ ResultSet rs = stmt.executeQuery(
+ "SELECT 1 FROM pg_database WHERE datname = '" + dbName + "'");
+
+ if (!rs.next()) {
+ stmt.execute("CREATE DATABASE " + dbName);
+ System.out.println("Database '" + dbName + "' created successfully");
+ }
+ } catch (Exception e) {
+ System.err.println("Failed to create database '" + dbName + "': " + e.getMessage());
+ }
+ }
+
+ private static String extractDatabaseName(String url) {
+ String withoutParams = url.contains("?") ? url.substring(0, url.indexOf('?')) : url;
+ return withoutParams.substring(withoutParams.lastIndexOf('/') + 1);
+ }
+}
diff --git a/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/TypesenseConfig.java b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/TypesenseConfig.java
new file mode 100644
index 0000000..0a9db11
--- /dev/null
+++ b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/TypesenseConfig.java
@@ -0,0 +1,40 @@
+package org.typesense.full_text_search.config;
+
+import java.time.Duration;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.typesense.api.Client;
+import org.typesense.resources.Node;
+
+@Configuration
+public class TypesenseConfig {
+
+ @Value("${typesense.protocol}")
+ private String protocol;
+
+ @Value("${typesense.host}")
+ private String host;
+
+ @Value("${typesense.port}")
+ private String port;
+
+ @Value("${typesense.api-key}")
+ private String apiKey;
+
+ @Value("${typesense.connection-timeout-seconds}")
+ private int connectionTimeoutSeconds;
+
+ @Bean
+ public Client typesenseClient() {
+ Node node = new Node(protocol, host, port);
+ org.typesense.api.Configuration configuration = new org.typesense.api.Configuration(
+ List.of(node),
+ Duration.ofSeconds(connectionTimeoutSeconds),
+ apiKey
+ );
+ return new Client(configuration);
+ }
+}
diff --git a/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/WebConfig.java b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/WebConfig.java
new file mode 100644
index 0000000..2f8d595
--- /dev/null
+++ b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/config/WebConfig.java
@@ -0,0 +1,18 @@
+package org.typesense.full_text_search.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOrigins("*")
+ .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+ .allowedHeaders("Origin", "Content-Type", "Accept", "Authorization")
+ .exposedHeaders("Content-Length");
+ }
+}
diff --git a/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/controller/BookController.java b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/controller/BookController.java
new file mode 100644
index 0000000..56f2225
--- /dev/null
+++ b/typesense-springboot-full-text-search/src/main/java/org/typesense/full_text_search/controller/BookController.java
@@ -0,0 +1,99 @@
+package org.typesense.full_text_search.controller;
+
+import java.util.Map;
+
+import org.springframework.data.domain.Page;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.typesense.full_text_search.model.Book;
+import org.typesense.full_text_search.service.BookService;
+import org.typesense.full_text_search.service.TypesenseService;
+
+@RestController
+@RequestMapping("/books")
+public class BookController {
+
+ private final BookService bookService;
+ private final TypesenseService typesenseService;
+
+ public BookController(BookService bookService, TypesenseService typesenseService) {
+ this.bookService = bookService;
+ this.typesenseService = typesenseService;
+ }
+
+ @PostMapping
+ public ResponseEntity