brainy/docs/guides/vue-integration.md
David Snelling 35b9d7ef43 refactor(8.0)!: remove orphaned zero-config subsystem + dead cloud/progressive-init storage vestige
The old config-generation subsystem (src/config/ + autoConfiguration.ts) was
superseded during the 8.0 rework and never wired into init(): it emitted settings
for a partitioning subsystem that no longer exists and probed deleted cloud env
vars. The live zero-config path is inline — recall preset → HNSW knobs, storage
auto-detect, auto persistMode, container-memory-aware cache sizing.

The storage progressive-init / cloud-detection cluster was equally dead after the
cloud adapters were dropped: isCloudStorage() is permanently false (no overriders),
scheduleBackgroundInit/runBackgroundInit were never called (the latter an empty
body), initMode was never assigned, and Brainy.isFullyInitialized()/
awaitBackgroundInit() were always-trivial with zero callers. scheduleCountPersist()
collapses to its only-ever-taken immediate write-through path.

Removed:
- src/config/{index,zeroConfig,storageAutoConfig,modelAutoConfig,sharedConfigManager}.ts
- src/utils/autoConfiguration.ts + the inert BrainyZeroConfig export
- Brainy.isFullyInitialized()/awaitBackgroundInit() (+ BrainyInterface decls)
- InitMode type, isCloudStorage/detectCloudEnvironment/resolveInitMode,
  scheduleBackgroundInit/runBackgroundInit/ensureValidatedForWrite and their state
- Dead cloud env-var probes (K_SERVICE/K_REVISION/AWS_LAMBDA_FUNCTION_NAME/
  FUNCTIONS_TARGET/AZURE_FUNCTIONS_ENVIRONMENT)

Kept (verified live): production-detection logging (environment.ts), container-
memory cache sizing (memoryDetection/paramValidation), on-disk hash bucketing
(sharding.ts).

Docs: scrubbed deleted-subsystem references (JS quantization knobs, cloud/OPFS
adapters, partitioning, old zero-config API) across 14 files; deleted two wholly-
obsolete feature docs (complete-feature-list, v3-features); rewrote
architecture/zero-config for 8.0.

~3,700 LOC removed. Build clean; 1392 unit + 24 db-mvcc green.
2026-06-15 11:11:21 -07:00

28 KiB

Vue.js Integration Guide

Complete guide to integrating Brainy with Vue.js applications, covering Vue 3, Nuxt.js, Composition API, Options API, and advanced patterns.

Runtime: Brainy 8.0 runs on Node.js 22+ and Bun, server-side only — it is not a browser library. In Vue apps, Brainy lives behind an HTTP endpoint (a Nuxt server route, or any Node/Bun backend), and your components call that endpoint. The composables, plugin, and store below are wrappers around fetch; the single Brainy instance is created once on the server (see Nuxt Server Route).

🚀 Quick Start

Installation

npm create vue@latest my-brainy-app
cd my-brainy-app
npm install
npm install @soulcraft/brainy

Basic Setup

The composable talks to a Brainy-backed endpoint (see Nuxt Server Route). It is always "ready" because there is no client-side initialization — the server owns the Brainy instance.

// src/composables/useBrainy.js
import { ref } from 'vue'

export function useBrainy(endpoint = '/api/brain') {
  const error = ref(null)

  const search = async (query, options = {}) => {
    error.value = null
    try {
      const res = await fetch(`${endpoint}/search`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, options })
      })
      if (!res.ok) throw new Error(`Search failed: ${res.status}`)
      return (await res.json()).results
    } catch (err) {
      error.value = err.message
      console.error('Brainy search failed:', err)
      return []
    }
  }

  const add = async (data, type, metadata) => {
    const res = await fetch(`${endpoint}/add`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ data, type, metadata })
    })
    return (await res.json()).id
  }

  const stats = async () => {
    const res = await fetch(`${endpoint}/stats`)
    return await res.json()
  }

  return { search, add, stats, error: readonly(error) }
}

🧩 Composition API

Search Component

<!-- src/components/Search.vue -->
<template>
  <div class="search-container">
    <div class="search-input">
      <input
        v-model="query"
        @input="handleSearch"
        placeholder="Search..."
        class="input"
      />
    </div>

    <div v-if="loading" class="loading">
      Searching...
    </div>

    <div v-else class="results">
      <div
        v-for="result in results"
        :key="result.id"
        class="result-item"
      >
        <h3>{{ result.data }}</h3>
        <div class="result-meta">
          <span>Score: {{ (result.score * 100).toFixed(1) }}%</span>
          <span v-if="result.metadata?.type">
            Type: {{ result.metadata.type }}
          </span>
        </div>
      </div>

      <div v-if="query && !loading && results.length === 0" class="no-results">
        No results found for "{{ query }}"
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useBrainy } from '../composables/useBrainy'
import { debounce } from '../utils/debounce'

const { search: searchBrain } = useBrainy()

const query = ref('')
const results = ref([])
const loading = ref(false)

const search = async (searchQuery) => {
  if (!searchQuery.trim()) {
    results.value = []
    return
  }

  loading.value = true
  try {
    results.value = await searchBrain(searchQuery)
  } catch (error) {
    console.error('Search error:', error)
    results.value = []
  } finally {
    loading.value = false
  }
}

const debouncedSearch = debounce(search, 300)
const handleSearch = () => debouncedSearch(query.value)

// Watch for query changes
watch(query, (newQuery) => {
  if (!newQuery.trim()) {
    results.value = []
    loading.value = false
  }
})
</script>

<style scoped>
.search-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.search-input {
  margin-bottom: 1.5rem;
}

.input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 0.5rem;
  font-size: 1rem;
}

.input:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  color: #6b7280;
}

.spinner {
  width: 1.5rem;
  height: 1.5rem;
  border: 2px solid #e5e7eb;
  border-top: 2px solid #3b82f6;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-right: 0.5rem;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.results {
  space-y: 1rem;
}

.result-item {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  padding: 1rem;
  margin-bottom: 1rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.result-item h3 {
  margin: 0 0 0.5rem 0;
  font-size: 1.125rem;
  font-weight: 600;
}

.result-meta {
  display: flex;
  justify-content: space-between;
  font-size: 0.875rem;
  color: #6b7280;
}

.no-results {
  text-align: center;
  padding: 2rem;
  color: #6b7280;
}
</style>

Data Management

<!-- src/components/DataManager.vue -->
<template>
  <div class="data-manager">
    <h2>Data Management</h2>

    <div>
      <!-- Add Data Form -->
      <form @submit.prevent="addData" class="add-form">
        <h3>Add New Data</h3>
        <div class="form-group">
          <label for="data">Data:</label>
          <textarea
            id="data"
            v-model="newItem.data"
            placeholder="Enter data..."
            required
          ></textarea>
        </div>
        <div class="form-group">
          <label for="type">Type:</label>
          <select id="type" v-model="newItem.type">
            <option value="concept">Concept</option>
            <option value="document">Document</option>
            <option value="person">Person</option>
            <option value="project">Project</option>
          </select>
        </div>
        <div class="form-group">
          <label for="tags">Tags (comma-separated):</label>
          <input
            id="tags"
            v-model="newItem.tags"
            placeholder="tag1, tag2, tag3"
          />
        </div>
        <button type="submit" :disabled="!newItem.data.trim()">
          Add Data
        </button>
      </form>

      <!-- Stats -->
      <div v-if="stats" class="stats">
        <h3>Statistics</h3>
        <div class="stats-grid">
          <div class="stat-item">
            <span class="stat-label">Total Items:</span>
            <span class="stat-value">{{ stats.totalItems }}</span>
          </div>
          <div class="stat-item">
            <span class="stat-label">Storage Type:</span>
            <span class="stat-value">{{ stats.storageType }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useBrainy } from '../composables/useBrainy'

const { add, stats: fetchStats } = useBrainy()

const newItem = reactive({
  data: '',
  type: 'concept',
  tags: ''
})

const stats = ref(null)

onMounted(loadStats)

const addData = async () => {
  try {
    const metadata = {
      addedAt: new Date().toISOString(),
      tags: newItem.tags.split(',').map(tag => tag.trim()).filter(Boolean)
    }

    await add(newItem.data, newItem.type, metadata)

    // Reset form
    newItem.data = ''
    newItem.type = 'concept'
    newItem.tags = ''

    // Refresh stats
    await loadStats()
  } catch (error) {
    console.error('Failed to add data:', error)
  }
}

const loadStats = async () => {
  try {
    stats.value = await fetchStats()
  } catch (error) {
    console.error('Failed to load stats:', error)
  }
}
</script>

<style scoped>
.data-manager {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
}

.add-form {
  background: #f9fafb;
  padding: 1.5rem;
  border-radius: 0.5rem;
  margin-bottom: 2rem;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.25rem;
  font-weight: 500;
}

.form-group input,
.form-group textarea,
.form-group select {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #d1d5db;
  border-radius: 0.25rem;
}

.form-group textarea {
  height: 100px;
  resize: vertical;
}

button {
  background: #3b82f6;
  color: white;
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 0.25rem;
  cursor: pointer;
}

button:disabled {
  background: #9ca3af;
  cursor: not-allowed;
}

.stats {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  padding: 1.5rem;
}

.stats-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

.stat-item {
  display: flex;
  justify-content: space-between;
}

.stat-label {
  font-weight: 500;
}

.stat-value {
  color: #3b82f6;
  font-weight: 600;
}
</style>

🏗️ Options API

Search Component (Options API)

<!-- src/components/SearchOptions.vue -->
<template>
  <div class="search-container">
    <input
      v-model="query"
      @input="handleSearch"
      placeholder="Search..."
      class="search-input"
    />

    <div v-if="loading" class="loading">Searching...</div>

    <div class="results">
      <div
        v-for="result in results"
        :key="result.id"
        class="result-item"
      >
        <h3>{{ result.data }}</h3>
        <p>Score: {{ (result.score * 100).toFixed(1) }}%</p>
      </div>
    </div>
  </div>
</template>

<script>
import { debounce } from '../utils/debounce'

export default {
  name: 'SearchOptions',
  data() {
    return {
      query: '',
      results: [],
      loading: false
    }
  },
  methods: {
    async search(query) {
      if (!query.trim()) {
        this.results = []
        return
      }

      this.loading = true
      try {
        const res = await fetch('/api/brain/search', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ query })
        })
        this.results = (await res.json()).results
      } catch (error) {
        console.error('Search error:', error)
        this.results = []
      } finally {
        this.loading = false
      }
    },
    handleSearch: debounce(function() {
      this.search(this.query)
    }, 300)
  },
  watch: {
    query(newQuery) {
      if (!newQuery.trim()) {
        this.results = []
        this.loading = false
      }
    }
  }
}
</script>

🔌 Vue Plugin

Global Brainy Plugin

The plugin exposes a global $searchBrain helper that calls your Brainy-backed endpoint. Brainy itself runs on the server (see Nuxt Server Route).

// src/plugins/brainy.js
export default {
  install(app, options = {}) {
    const endpoint = options.endpoint ?? '/api/brain'

    // Add global search method (calls the server endpoint)
    app.config.globalProperties.$searchBrain = async (query, searchOptions) => {
      const res = await fetch(`${endpoint}/search`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, options: searchOptions })
      })
      return (await res.json()).results
    }
  }
}

Plugin Usage

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import BrainyPlugin from './plugins/brainy'

const app = createApp(App)

app.use(BrainyPlugin, {
  endpoint: '/api/brain'
})

app.mount('#app')
<!-- Component using the plugin -->
<template>
  <div>
    <input v-model="query" @input="search" />
    <div v-for="result in results" :key="result.id">
      {{ result.data }}
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      query: '',
      results: []
    }
  },
  methods: {
    async search() {
      // Use the global method
      this.results = await this.$searchBrain(this.query)
    }
  }
}
</script>

🏰 Nuxt.js Integration

Nuxt's server engine (Nitro) is the natural home for Brainy: it runs on Node/Bun, so the Brainy instance lives in server/, and pages/components call its routes.

Nuxt Server Route

// server/utils/brain.js  (server-only — Nitro never bundles this into the client)
import { Brainy } from '@soulcraft/brainy'

let brainPromise

export function getBrain() {
  if (!brainPromise) {
    brainPromise = (async () => {
      // new Brainy() auto-detects filesystem persistence on Node
      const brain = new Brainy()
      await brain.init()
      return brain
    })()
  }
  return brainPromise
}
// server/api/brain/search.post.js
import { getBrain } from '../../utils/brain'

export default defineEventHandler(async (event) => {
  const { query, options } = await readBody(event)
  const brain = await getBrain()
  return { results: await brain.find(query, options) }
})
// server/api/brain/add.post.js
import { getBrain } from '../../utils/brain'

export default defineEventHandler(async (event) => {
  const { data, type, metadata } = await readBody(event)
  const brain = await getBrain()
  return { id: await brain.add({ data, type, metadata }) }
})
// server/api/brain/stats.get.js
import { getBrain } from '../../utils/brain'

export default defineEventHandler(async () => {
  const brain = await getBrain()
  return await brain.stats()
})

Nuxt Composable

// composables/useBrainy.js
export const useBrainy = () => {
  const search = async (query, options = {}) => {
    return (await $fetch('/api/brain/search', {
      method: 'POST',
      body: { query, options }
    })).results
  }

  const add = async (data, type, metadata) => {
    return (await $fetch('/api/brain/add', {
      method: 'POST',
      body: { data, type, metadata }
    })).id
  }

  const stats = async () => {
    return await $fetch('/api/brain/stats')
  }

  return { search, add, stats }
}

Nuxt Page Example

<!-- pages/search.vue -->
<template>
  <div>
    <h1>Search</h1>

    <input
      v-model="query"
      @input="handleSearch"
      placeholder="Search..."
    />

    <div v-if="searching" class="loading">Searching...</div>

    <div class="results">
      <div v-for="result in results" :key="result.id" class="result">
        <h3>{{ result.data }}</h3>
        <p>Score: {{ (result.score * 100).toFixed(1) }}%</p>
      </div>
    </div>
  </div>
</template>

<script setup>
const { search } = useBrainy()

const query = ref('')
const results = ref([])
const searching = ref(false)

const handleSearch = debounce(async () => {
  if (!query.value.trim()) {
    results.value = []
    return
  }

  searching.value = true
  try {
    results.value = await search(query.value)
  } catch (error) {
    console.error('Search failed:', error)
  } finally {
    searching.value = false
  }
}, 300)
</script>

🛠️ Advanced Patterns

Global State Management with Pinia

The store caches results and stats client-side; all Brainy work happens behind the server endpoints.

// stores/brainy.js
import { defineStore } from 'pinia'

export const useBrainyStore = defineStore('brainy', () => {
  const error = ref(null)
  const stats = ref(null)

  const search = async (query, options = {}) => {
    error.value = null
    try {
      const res = await fetch('/api/brain/search', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, options })
      })
      return (await res.json()).results
    } catch (err) {
      error.value = err.message
      throw err
    }
  }

  const add = async (data, type, metadata) => {
    const res = await fetch('/api/brain/add', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ data, type, metadata })
    })
    const { id } = await res.json()
    await loadStats() // Refresh stats
    return id
  }

  const loadStats = async () => {
    try {
      const res = await fetch('/api/brain/stats')
      stats.value = await res.json()
    } catch (err) {
      console.error('Failed to load stats:', err)
    }
  }

  return {
    error: readonly(error),
    stats: readonly(stats),
    search,
    add,
    loadStats
  }
})

Real-time Search Component

<!-- src/components/RealTimeSearch.vue -->
<template>
  <div class="realtime-search">
    <div class="search-bar">
      <input
        ref="searchInput"
        v-model="query"
        @input="handleInput"
        @focus="showResults = true"
        @blur="handleBlur"
        placeholder="Search..."
        class="search-input"
      />
      <div v-if="loading" class="search-spinner"></div>
    </div>

    <Transition name="dropdown">
      <div
        v-if="showResults && (results.length > 0 || query.trim())"
        class="search-dropdown"
      >
        <div
          v-for="(result, index) in results"
          :key="result.id"
          :class="['result-item', { active: selectedIndex === index }]"
          @mousedown="selectResult(result)"
          @mouseenter="selectedIndex = index"
        >
          <div class="result-content">
            <span class="result-text">{{ result.data }}</span>
            <span class="result-score">{{ (result.score * 100).toFixed(0) }}%</span>
          </div>
        </div>

        <div v-if="query.trim() && results.length === 0 && !loading" class="no-results">
          No results found
        </div>
      </div>
    </Transition>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue'
import { useBrainyStore } from '../stores/brainy'
import { debounce } from '../utils/debounce'

const emit = defineEmits(['select'])

const brainyStore = useBrainyStore()

const searchInput = ref(null)
const query = ref('')
const results = ref([])
const loading = ref(false)
const showResults = ref(false)
const selectedIndex = ref(-1)

const search = async (searchQuery) => {
  if (!searchQuery.trim()) {
    results.value = []
    return
  }

  loading.value = true
  try {
    const searchResults = await brainyStore.search(searchQuery, { limit: 10 })
    results.value = searchResults
  } catch (error) {
    console.error('Search error:', error)
    results.value = []
  } finally {
    loading.value = false
  }
}

const debouncedSearch = debounce(search, 200)

const handleInput = () => {
  selectedIndex.value = -1
  debouncedSearch(query.value)
}

const handleBlur = () => {
  // Delay hiding to allow clicking on results
  setTimeout(() => {
    showResults.value = false
  }, 150)
}

const selectResult = (result) => {
  query.value = result.data
  showResults.value = false
  emit('select', result)
}

// Keyboard navigation
const handleKeydown = (event) => {
  if (!showResults.value) return

  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault()
      selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1)
      break
    case 'ArrowUp':
      event.preventDefault()
      selectedIndex.value = Math.max(selectedIndex.value - 1, -1)
      break
    case 'Enter':
      event.preventDefault()
      if (selectedIndex.value >= 0) {
        selectResult(results.value[selectedIndex.value])
      }
      break
    case 'Escape':
      showResults.value = false
      searchInput.value?.blur()
      break
  }
}

onMounted(() => {
  document.addEventListener('keydown', handleKeydown)
})

onUnmounted(() => {
  document.removeEventListener('keydown', handleKeydown)
})
</script>

<style scoped>
.realtime-search {
  position: relative;
  width: 100%;
}

.search-bar {
  position: relative;
  display: flex;
  align-items: center;
}

.search-input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 0.5rem;
  font-size: 1rem;
}

.search-input:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.search-spinner {
  position: absolute;
  right: 0.75rem;
  animation: spin 1s linear infinite;
  color: #6b7280;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.search-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: white;
  border: 1px solid #d1d5db;
  border-radius: 0.5rem;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  max-height: 300px;
  overflow-y: auto;
}

.result-item {
  padding: 0.75rem;
  cursor: pointer;
  border-bottom: 1px solid #f3f4f6;
}

.result-item:last-child {
  border-bottom: none;
}

.result-item:hover,
.result-item.active {
  background: #f3f4f6;
}

.result-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.result-text {
  flex: 1;
  margin-right: 0.5rem;
}

.result-score {
  font-size: 0.875rem;
  color: #6b7280;
}

.no-results {
  padding: 1rem;
  text-align: center;
  color: #6b7280;
}

.dropdown-enter-active,
.dropdown-leave-active {
  transition: all 0.2s;
}

.dropdown-enter-from,
.dropdown-leave-to {
  opacity: 0;
  transform: translateY(-10px);
}
</style>

📊 Performance Optimization

Lazy Loading

<!-- src/components/LazyBrainComponent.vue -->
<template>
  <div>
    <button v-if="!loaded" @click="loadBrain" class="load-button">
      Load AI Search
    </button>

    <Suspense v-else>
      <template #default>
        <SearchComponent />
      </template>
      <template #fallback>
        <div class="loading">Loading AI...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue'

const loaded = ref(false)

const SearchComponent = defineAsyncComponent(() =>
  import('./Search.vue')
)

const loadBrain = () => {
  loaded.value = true
}
</script>

Virtual Scrolling for Large Results

<!-- src/components/VirtualResults.vue -->
<template>
  <div ref="container" class="virtual-container" @scroll="onScroll">
    <div :style="{ height: totalHeight + 'px' }" class="virtual-spacer">
      <div
        :style="{ transform: `translateY(${offsetY}px)` }"
        class="virtual-content"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          class="result-item"
          :style="{ height: itemHeight + 'px' }"
        >
          <h3>{{ item.data }}</h3>
          <p>Score: {{ (item.score * 100).toFixed(1) }}%</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'

const props = defineProps({
  items: Array,
  itemHeight: { type: Number, default: 80 }
})

const container = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(400)

const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleStart = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const visibleEnd = computed(() => Math.min(
  visibleStart.value + Math.ceil(containerHeight.value / props.itemHeight) + 1,
  props.items.length
))
const visibleItems = computed(() =>
  props.items.slice(visibleStart.value, visibleEnd.value)
)
const offsetY = computed(() => visibleStart.value * props.itemHeight)

const onScroll = () => {
  scrollTop.value = container.value.scrollTop
}

onMounted(() => {
  containerHeight.value = container.value.clientHeight
})
</script>

<style scoped>
.virtual-container {
  height: 400px;
  overflow: auto;
}

.virtual-spacer {
  position: relative;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.result-item {
  padding: 1rem;
  border-bottom: 1px solid #e5e7eb;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
</style>

🧪 Testing

Component Testing with Vitest

The component talks to the server over fetch, so mock the endpoint, not Brainy itself.

// tests/components/Search.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Search from '../src/components/Search.vue'

// Mock the server endpoint
beforeEach(() => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({
      results: [{ id: '1', data: 'Test result', score: 0.9 }]
    })
  })
})

describe('Search Component', () => {
  let wrapper

  beforeEach(() => {
    wrapper = mount(Search)
  })

  it('renders search input', () => {
    expect(wrapper.find('input').exists()).toBe(true)
  })

  it('performs search when input changes', async () => {
    const input = wrapper.find('input')
    await input.setValue('test query')
    await input.trigger('input')

    // Wait for debounced search
    await new Promise(resolve => setTimeout(resolve, 350))

    expect(global.fetch).toHaveBeenCalled()
    expect(wrapper.text()).toContain('Test result')
  })
})

E2E Testing with Playwright

// tests/e2e/search.spec.js
import { test, expect } from '@playwright/test'

test('search functionality works', async ({ page }) => {
  await page.goto('/')

  // Wait for brain to initialize
  await page.waitForSelector('[data-testid="search-input"]')

  // Perform search
  await page.fill('[data-testid="search-input"]', 'test query')

  // Wait for results
  await page.waitForSelector('[data-testid="search-results"]')

  // Check results
  const results = await page.locator('[data-testid="result-item"]')
  await expect(results).toHaveCountGreaterThan(0)
})

🚀 Production Tips

Bundle Optimization

Keep Brainy out of the client bundle — it belongs to the server build only. Mark it external for SSR so the bundler resolves it at runtime instead of inlining it.

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  ssr: {
    external: ['@soulcraft/brainy']
  }
})

Error Handling

Wrap the endpoint call with retry/backoff so transient network failures don't surface to the user.

// src/composables/useBrainyWithErrorHandling.js
import { ref } from 'vue'

export function useBrainyWithErrorHandling(endpoint = '/api/brain') {
  const error = ref(null)

  const search = async (query, options = {}, maxRetries = 3) => {
    error.value = null
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const res = await fetch(`${endpoint}/search`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ query, options })
        })
        if (!res.ok) throw new Error(`Search failed: ${res.status}`)
        return (await res.json()).results
      } catch (err) {
        error.value = err.message
        if (attempt === maxRetries) throw err
        // Exponential backoff before retrying
        await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)))
      }
    }
  }

  return {
    error: readonly(error),
    search
  }
}

🎯 Complete Example

Here's a complete Vue 3 application structure:

my-brainy-vue-app/
├── server/                      # Server-side (Node/Bun) — hosts Brainy
│   ├── utils/
│   │   └── brain.js             # Shared Brainy instance (getBrain)
│   └── api/brain/
│       ├── search.post.js
│       ├── add.post.js
│       └── stats.get.js
├── src/
│   ├── components/
│   │   ├── Search.vue
│   │   ├── DataManager.vue
│   │   └── RealTimeSearch.vue
│   ├── composables/
│   │   ├── useBrainy.js
│   │   └── useBrainyWithErrorHandling.js
│   ├── stores/
│   │   └── brainy.js
│   ├── utils/
│   │   └── debounce.js
│   ├── App.vue
│   └── main.js
├── tests/
│   ├── components/
│   └── e2e/
├── vite.config.js
└── package.json

This provides a complete, production-ready Vue.js application: Brainy runs on the server, and the client talks to it over HTTP.

📚 Next Steps