@mars-stack/cli 0.2.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,581 @@
1
+ # Skill: Configure Search
2
+
3
+ Set up search functionality in a MARS application using Postgres full-text search, Algolia, or Meilisearch.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add search, full-text search, search bar, autocomplete, or indexing (e.g., "add search to the app", "let users search posts", "add Algolia").
8
+
9
+ ## Prerequisites
10
+
11
+ - `appConfig.services.search.provider` set to `'postgres'`, `'algolia'`, or `'meilisearch'`
12
+ - For Algolia/Meilisearch: an account with the respective provider
13
+ - At least one Prisma model with searchable text fields
14
+
15
+ ## Provider Interface
16
+
17
+ All providers implement a common interface:
18
+
19
+ ```typescript
20
+ // src/features/search/types.ts
21
+ export interface SearchResult<T = Record<string, unknown>> {
22
+ id: string;
23
+ score: number;
24
+ data: T;
25
+ }
26
+
27
+ export interface SearchOptions {
28
+ model: string;
29
+ page?: number;
30
+ limit?: number;
31
+ }
32
+
33
+ export interface SearchProvider {
34
+ search(query: string, options: SearchOptions): Promise<{
35
+ results: SearchResult[];
36
+ total: number;
37
+ page: number;
38
+ totalPages: number;
39
+ }>;
40
+ index(model: string, id: string, data: Record<string, unknown>): Promise<void>;
41
+ remove(model: string, id: string): Promise<void>;
42
+ }
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Option A: Postgres Full-Text Search
48
+
49
+ No external dependencies. Uses PostgreSQL's built-in `tsvector` and `tsquery` capabilities.
50
+
51
+ ### Step 1: Add GIN Index to Prisma Schema
52
+
53
+ Prisma does not natively support `tsvector` columns, so use a raw migration. First, add a standard index as a placeholder, then apply a raw SQL migration.
54
+
55
+ ```prisma
56
+ // In your model's .prisma file, add a comment for reference:
57
+ // Full-text search index applied via migration (see Step 2)
58
+ ```
59
+
60
+ ### Step 2: Create Raw SQL Migration
61
+
62
+ ```bash
63
+ yarn prisma migrate dev --name add-search-index --create-only
64
+ ```
65
+
66
+ Edit the generated migration SQL:
67
+
68
+ ```sql
69
+ -- Add tsvector column and GIN index for full-text search
70
+ ALTER TABLE "Post" ADD COLUMN IF NOT EXISTS search_vector tsvector
71
+ GENERATED ALWAYS AS (
72
+ setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
73
+ setweight(to_tsvector('english', coalesce(body, '')), 'B')
74
+ ) STORED;
75
+
76
+ CREATE INDEX IF NOT EXISTS "Post_search_vector_idx" ON "Post" USING GIN (search_vector);
77
+ ```
78
+
79
+ Then apply: `yarn prisma migrate dev`
80
+
81
+ ### Step 3: Search Service
82
+
83
+ ```typescript
84
+ // src/features/search/server/postgres.ts
85
+ import 'server-only';
86
+
87
+ import { prisma } from '@/lib/prisma';
88
+ import { Prisma } from '@db';
89
+ import type { SearchResult, SearchOptions } from '@/features/search/types';
90
+
91
+ function sanitizeQuery(query: string): string {
92
+ return query
93
+ .replace(/[^\w\s]/g, ' ')
94
+ .trim()
95
+ .split(/\s+/)
96
+ .filter(Boolean)
97
+ .join(' & ');
98
+ }
99
+
100
+ export async function searchPostgres(
101
+ query: string,
102
+ options: SearchOptions,
103
+ ): Promise<{ results: SearchResult[]; total: number; page: number; totalPages: number }> {
104
+ const { model, page = 1, limit = 20 } = options;
105
+ const sanitized = sanitizeQuery(query);
106
+
107
+ if (!sanitized) {
108
+ return { results: [], total: 0, page, totalPages: 0 };
109
+ }
110
+
111
+ const offset = (page - 1) * limit;
112
+ const tableName = Prisma.raw(`"${model}"`);
113
+
114
+ const results = await prisma.$queryRaw<Array<{ id: string; rank: number }>>`
115
+ SELECT id, ts_rank(search_vector, plainto_tsquery('english', ${sanitized})) AS rank
116
+ FROM ${tableName}
117
+ WHERE search_vector @@ plainto_tsquery('english', ${sanitized})
118
+ ORDER BY rank DESC
119
+ LIMIT ${limit}
120
+ OFFSET ${offset}
121
+ `;
122
+
123
+ const countResult = await prisma.$queryRaw<[{ count: bigint }]>`
124
+ SELECT COUNT(*) as count
125
+ FROM ${tableName}
126
+ WHERE search_vector @@ plainto_tsquery('english', ${sanitized})
127
+ `;
128
+
129
+ const total = Number(countResult[0].count);
130
+
131
+ return {
132
+ results: results.map((r) => ({ id: r.id, score: r.rank, data: r as Record<string, unknown> })),
133
+ total,
134
+ page,
135
+ totalPages: Math.ceil(total / limit),
136
+ };
137
+ }
138
+ ```
139
+
140
+ Key rules:
141
+ - Always sanitize input to prevent SQL injection -- strip non-word characters and join with `&` for AND semantics.
142
+ - Use `plainto_tsquery` for user-facing search (safer than `to_tsquery`).
143
+ - Use `ts_rank` for relevance scoring.
144
+ - Weighted columns: `'A'` weight for titles, `'B'` for body text.
145
+
146
+ ---
147
+
148
+ ## Option B: Algolia
149
+
150
+ ### Step 1: Install Algolia
151
+
152
+ ```bash
153
+ yarn add algoliasearch
154
+ ```
155
+
156
+ ### Step 2: Environment Variables
157
+
158
+ ```bash
159
+ # Server-side (secret -- never expose to client)
160
+ ALGOLIA_APP_ID="your-app-id"
161
+ ALGOLIA_API_KEY="your-admin-api-key"
162
+
163
+ # Client-side (search-only key -- safe to expose)
164
+ NEXT_PUBLIC_ALGOLIA_APP_ID="your-app-id"
165
+ NEXT_PUBLIC_ALGOLIA_SEARCH_KEY="your-search-only-key"
166
+ ```
167
+
168
+ ### Step 3: Algolia Service
169
+
170
+ ```typescript
171
+ // src/features/search/server/algolia.ts
172
+ import 'server-only';
173
+
174
+ import { algoliasearch } from 'algoliasearch';
175
+
176
+ let _client: ReturnType<typeof algoliasearch> | null = null;
177
+
178
+ function getAlgoliaClient() {
179
+ if (_client) return _client;
180
+
181
+ const appId = process.env.ALGOLIA_APP_ID;
182
+ const apiKey = process.env.ALGOLIA_API_KEY;
183
+
184
+ if (!appId || !apiKey) {
185
+ throw new Error(
186
+ 'ALGOLIA_APP_ID and ALGOLIA_API_KEY are required.\n'
187
+ + ' → Get your keys from https://dashboard.algolia.com/account/api-keys\n'
188
+ + ' → Add them to your .env file',
189
+ );
190
+ }
191
+
192
+ _client = algoliasearch(appId, apiKey);
193
+ return _client;
194
+ }
195
+
196
+ function getIndexName(model: string): string {
197
+ const env = process.env.NODE_ENV || 'development';
198
+ const appName = process.env.APP_NAME || 'mars';
199
+ return `${appName}_${model}_${env}`;
200
+ }
201
+
202
+ export async function indexDocument(model: string, id: string, data: Record<string, unknown>) {
203
+ const client = getAlgoliaClient();
204
+ await client.saveObject({
205
+ indexName: getIndexName(model),
206
+ body: { objectID: id, ...data },
207
+ });
208
+ }
209
+
210
+ export async function removeDocument(model: string, id: string) {
211
+ const client = getAlgoliaClient();
212
+ await client.deleteObject({
213
+ indexName: getIndexName(model),
214
+ objectID: id,
215
+ });
216
+ }
217
+
218
+ export async function searchAlgolia(query: string, model: string, page: number = 0, hitsPerPage: number = 20) {
219
+ const client = getAlgoliaClient();
220
+ const result = await client.searchSingleIndex({
221
+ indexName: getIndexName(model),
222
+ searchParams: { query, page, hitsPerPage },
223
+ });
224
+
225
+ return {
226
+ results: (result.hits || []).map((hit: Record<string, unknown>) => ({
227
+ id: hit.objectID as string,
228
+ score: 1,
229
+ data: hit,
230
+ })),
231
+ total: result.nbHits ?? 0,
232
+ page: (result.page ?? 0) + 1,
233
+ totalPages: result.nbPages ?? 0,
234
+ };
235
+ }
236
+ ```
237
+
238
+ ### Step 4: Sync on Create/Update/Delete
239
+
240
+ Add indexing calls to your server mutations:
241
+
242
+ ```typescript
243
+ // In your feature's server module
244
+ import { indexDocument, removeDocument } from '@/features/search/server/algolia';
245
+
246
+ export async function createPost(userId: string, data: CreatePostInput) {
247
+ const post = await prisma.post.create({ data: { ...data, userId } });
248
+
249
+ await indexDocument('Post', post.id, {
250
+ title: post.title,
251
+ body: post.body,
252
+ createdAt: post.createdAt.toISOString(),
253
+ });
254
+
255
+ return post;
256
+ }
257
+
258
+ export async function deletePost(userId: string, postId: string) {
259
+ await prisma.post.delete({ where: { id: postId, userId } });
260
+ await removeDocument('Post', postId);
261
+ }
262
+ ```
263
+
264
+ ### Step 5: Client-Side Search Component
265
+
266
+ ```typescript
267
+ // src/features/search/components/search-input.tsx
268
+ 'use client';
269
+
270
+ import { useState, useCallback, useEffect, useRef } from 'react';
271
+ import { algoliasearch } from 'algoliasearch/lite';
272
+
273
+ const searchClient = algoliasearch(
274
+ process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
275
+ process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!,
276
+ );
277
+
278
+ interface SearchInputProps {
279
+ indexName: string;
280
+ onResults: (hits: Array<Record<string, unknown>>) => void;
281
+ placeholder?: string;
282
+ debounceMs?: number;
283
+ }
284
+
285
+ export function SearchInput({ indexName, onResults, placeholder = 'Search...', debounceMs = 300 }: SearchInputProps) {
286
+ const [query, setQuery] = useState('');
287
+ const timerRef = useRef<ReturnType<typeof setTimeout>>();
288
+
289
+ const performSearch = useCallback(async (q: string) => {
290
+ if (!q.trim()) {
291
+ onResults([]);
292
+ return;
293
+ }
294
+
295
+ const { hits } = await searchClient.searchSingleIndex({
296
+ indexName,
297
+ searchParams: { query: q },
298
+ });
299
+ onResults(hits as Array<Record<string, unknown>>);
300
+ }, [indexName, onResults]);
301
+
302
+ useEffect(() => {
303
+ clearTimeout(timerRef.current);
304
+ timerRef.current = setTimeout(() => performSearch(query), debounceMs);
305
+ return () => clearTimeout(timerRef.current);
306
+ }, [query, debounceMs, performSearch]);
307
+
308
+ return (
309
+ <input
310
+ type="search"
311
+ value={query}
312
+ onChange={(e) => setQuery(e.target.value)}
313
+ placeholder={placeholder}
314
+ className="w-full rounded-lg border border-border-primary bg-surface-primary px-4 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-primary"
315
+ />
316
+ );
317
+ }
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Option C: Meilisearch
323
+
324
+ ### Step 1: Install Meilisearch Client
325
+
326
+ ```bash
327
+ yarn add meilisearch
328
+ ```
329
+
330
+ ### Step 2: Environment Variables
331
+
332
+ ```bash
333
+ # Server-side
334
+ MEILISEARCH_HOST="http://localhost:7700"
335
+ MEILISEARCH_MASTER_KEY="your-master-key"
336
+
337
+ # Client-side (search-only)
338
+ NEXT_PUBLIC_MEILISEARCH_HOST="http://localhost:7700"
339
+ NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY="your-search-api-key"
340
+ ```
341
+
342
+ ### Step 3: Meilisearch Service
343
+
344
+ ```typescript
345
+ // src/features/search/server/meilisearch.ts
346
+ import 'server-only';
347
+
348
+ import { MeiliSearch } from 'meilisearch';
349
+
350
+ let _client: MeiliSearch | null = null;
351
+
352
+ function getMeilisearchClient(): MeiliSearch {
353
+ if (_client) return _client;
354
+
355
+ const host = process.env.MEILISEARCH_HOST;
356
+ const apiKey = process.env.MEILISEARCH_MASTER_KEY;
357
+
358
+ if (!host) {
359
+ throw new Error(
360
+ 'MEILISEARCH_HOST is required.\n'
361
+ + ' → Run Meilisearch locally: docker run -p 7700:7700 getmeili/meilisearch\n'
362
+ + ' → Add MEILISEARCH_HOST to your .env file',
363
+ );
364
+ }
365
+
366
+ _client = new MeiliSearch({ host, apiKey });
367
+ return _client;
368
+ }
369
+
370
+ function getIndexName(model: string): string {
371
+ const env = process.env.NODE_ENV || 'development';
372
+ return `${model.toLowerCase()}_${env}`;
373
+ }
374
+
375
+ export async function configureIndex(model: string, searchableAttributes: string[]) {
376
+ const client = getMeilisearchClient();
377
+ const index = client.index(getIndexName(model));
378
+ await index.updateSearchableAttributes(searchableAttributes);
379
+ await index.updateFilterableAttributes(['userId', 'status', 'createdAt']);
380
+ await index.updateSortableAttributes(['createdAt']);
381
+ }
382
+
383
+ export async function indexDocument(model: string, id: string, data: Record<string, unknown>) {
384
+ const client = getMeilisearchClient();
385
+ await client.index(getIndexName(model)).addDocuments([{ id, ...data }]);
386
+ }
387
+
388
+ export async function removeDocument(model: string, id: string) {
389
+ const client = getMeilisearchClient();
390
+ await client.index(getIndexName(model)).deleteDocument(id);
391
+ }
392
+
393
+ export async function searchMeilisearch(
394
+ query: string,
395
+ model: string,
396
+ page: number = 1,
397
+ limit: number = 20,
398
+ ) {
399
+ const client = getMeilisearchClient();
400
+ const result = await client.index(getIndexName(model)).search(query, {
401
+ offset: (page - 1) * limit,
402
+ limit,
403
+ });
404
+
405
+ return {
406
+ results: result.hits.map((hit) => ({
407
+ id: hit.id as string,
408
+ score: 1,
409
+ data: hit as Record<string, unknown>,
410
+ })),
411
+ total: result.estimatedTotalHits ?? 0,
412
+ page,
413
+ totalPages: Math.ceil((result.estimatedTotalHits ?? 0) / limit),
414
+ };
415
+ }
416
+ ```
417
+
418
+ ### Step 4: Index Configuration
419
+
420
+ Run once on setup or in a seed script:
421
+
422
+ ```typescript
423
+ import { configureIndex } from '@/features/search/server/meilisearch';
424
+
425
+ await configureIndex('Post', ['title', 'body']);
426
+ await configureIndex('User', ['name', 'email']);
427
+ ```
428
+
429
+ ---
430
+
431
+ ## Search API Route
432
+
433
+ Regardless of provider, expose a unified search endpoint:
434
+
435
+ ```typescript
436
+ // src/app/api/protected/search/route.ts
437
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
438
+ import { NextResponse } from 'next/server';
439
+ import { z } from 'zod';
440
+
441
+ const searchSchema = z.object({
442
+ q: z.string().min(1).max(200),
443
+ model: z.string().min(1),
444
+ page: z.coerce.number().int().positive().default(1),
445
+ limit: z.coerce.number().int().positive().max(100).default(20),
446
+ });
447
+
448
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
449
+ try {
450
+ const url = new URL(request.url);
451
+ const params = searchSchema.parse({
452
+ q: url.searchParams.get('q'),
453
+ model: url.searchParams.get('model'),
454
+ page: url.searchParams.get('page') ?? 1,
455
+ limit: url.searchParams.get('limit') ?? 20,
456
+ });
457
+
458
+ // Import the appropriate provider based on appConfig
459
+ // Example with Postgres:
460
+ const { searchPostgres } = await import('@/features/search/server/postgres');
461
+ const results = await searchPostgres(params.q, {
462
+ model: params.model,
463
+ page: params.page,
464
+ limit: params.limit,
465
+ });
466
+
467
+ return NextResponse.json(results);
468
+ } catch (error) {
469
+ return handleApiError(error, { endpoint: '/api/protected/search' });
470
+ }
471
+ });
472
+ ```
473
+
474
+ ## Debounced Search Hook
475
+
476
+ A reusable hook for client-side search with debounce, usable with any provider:
477
+
478
+ ```typescript
479
+ // src/features/search/hooks/use-search.ts
480
+ 'use client';
481
+
482
+ import { useState, useCallback, useRef, useEffect } from 'react';
483
+
484
+ interface UseSearchOptions {
485
+ model: string;
486
+ debounceMs?: number;
487
+ limit?: number;
488
+ }
489
+
490
+ export function useSearch<T = Record<string, unknown>>({ model, debounceMs = 300, limit = 20 }: UseSearchOptions) {
491
+ const [query, setQuery] = useState('');
492
+ const [results, setResults] = useState<T[]>([]);
493
+ const [isLoading, setIsLoading] = useState(false);
494
+ const [total, setTotal] = useState(0);
495
+ const [page, setPage] = useState(1);
496
+ const timerRef = useRef<ReturnType<typeof setTimeout>>();
497
+
498
+ const performSearch = useCallback(async (q: string, p: number) => {
499
+ if (!q.trim()) {
500
+ setResults([]);
501
+ setTotal(0);
502
+ return;
503
+ }
504
+
505
+ setIsLoading(true);
506
+ try {
507
+ const params = new URLSearchParams({ q, model, page: String(p), limit: String(limit) });
508
+ const response = await fetch(`/api/protected/search?${params}`);
509
+ const data = await response.json();
510
+ setResults(data.results?.map((r: { data: T }) => r.data) ?? []);
511
+ setTotal(data.total ?? 0);
512
+ } finally {
513
+ setIsLoading(false);
514
+ }
515
+ }, [model, limit]);
516
+
517
+ useEffect(() => {
518
+ clearTimeout(timerRef.current);
519
+ timerRef.current = setTimeout(() => performSearch(query, page), debounceMs);
520
+ return () => clearTimeout(timerRef.current);
521
+ }, [query, page, debounceMs, performSearch]);
522
+
523
+ return { query, setQuery, results, isLoading, total, page, setPage };
524
+ }
525
+ ```
526
+
527
+ ## Config Integration
528
+
529
+ Add to `src/config/app.config.ts`:
530
+
531
+ ```typescript
532
+ export const appConfig = {
533
+ // ... existing config
534
+ services: {
535
+ // ... existing services
536
+ search: {
537
+ provider: 'postgres' as const, // 'postgres' | 'algolia' | 'meilisearch'
538
+ },
539
+ },
540
+ };
541
+ ```
542
+
543
+ ## Testing
544
+
545
+ ```typescript
546
+ import { describe, it, expect, vi } from 'vitest';
547
+
548
+ // Mock search service
549
+ vi.mock('@/features/search/server/postgres', () => ({
550
+ searchPostgres: vi.fn().mockResolvedValue({
551
+ results: [{ id: '1', score: 0.9, data: { title: 'Test' } }],
552
+ total: 1,
553
+ page: 1,
554
+ totalPages: 1,
555
+ }),
556
+ }));
557
+
558
+ // For Algolia/Meilisearch, mock the client:
559
+ vi.mock('algoliasearch', () => ({
560
+ algoliasearch: vi.fn(() => ({
561
+ searchSingleIndex: vi.fn().mockResolvedValue({ hits: [], nbHits: 0 }),
562
+ saveObject: vi.fn(),
563
+ deleteObject: vi.fn(),
564
+ })),
565
+ }));
566
+ ```
567
+
568
+ ## Checklist
569
+
570
+ - [ ] Provider chosen and configured in `app.config.ts`
571
+ - [ ] Environment variables set for chosen provider
572
+ - [ ] Search service module created with `import 'server-only'`
573
+ - [ ] For Postgres: tsvector column and GIN index added via migration
574
+ - [ ] For Algolia/Meilisearch: documents indexed on create/update/delete
575
+ - [ ] Search API route created at `GET /api/protected/search`
576
+ - [ ] Input sanitized to prevent injection (Postgres) or excessive queries
577
+ - [ ] Zod validation on search parameters
578
+ - [ ] Debounced search hook or component created for client-side use
579
+ - [ ] Uses auth wrapper (`withAuthNoParams`) on API route
580
+ - [ ] Design tokens used for search component styling (no raw color classes)
581
+ - [ ] Tests written for search service and API route