@gblikas/querykit 0.0.0

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 (118) hide show
  1. package/.cursor/BUGBOT.md +21 -0
  2. package/.cursor/rules/01-project-structure.mdc +77 -0
  3. package/.cursor/rules/02-typescript-standards.mdc +105 -0
  4. package/.cursor/rules/03-testing-standards.mdc +78 -0
  5. package/.cursor/rules/04-query-language.mdc +79 -0
  6. package/.cursor/rules/05-solid-principles.mdc +118 -0
  7. package/.cursor/rules/liqe-readme-docs.mdc +438 -0
  8. package/.devcontainer/devcontainer.json +25 -0
  9. package/.eslintignore +1 -0
  10. package/.eslintrc.js +39 -0
  11. package/.github/dependabot.yml +12 -0
  12. package/.github/workflows/ci.yml +114 -0
  13. package/.github/workflows/publish.yml +61 -0
  14. package/.husky/pre-commit +30 -0
  15. package/.prettierrc +10 -0
  16. package/CONTRIBUTING.md +187 -0
  17. package/LICENSE +674 -0
  18. package/README.md +237 -0
  19. package/dist/adapters/drizzle/index.d.ts +122 -0
  20. package/dist/adapters/drizzle/index.js +166 -0
  21. package/dist/adapters/index.d.ts +7 -0
  22. package/dist/adapters/index.js +25 -0
  23. package/dist/adapters/types.d.ts +60 -0
  24. package/dist/adapters/types.js +8 -0
  25. package/dist/index.d.ts +75 -0
  26. package/dist/index.js +118 -0
  27. package/dist/parser/index.d.ts +2 -0
  28. package/dist/parser/index.js +18 -0
  29. package/dist/parser/parser.d.ts +51 -0
  30. package/dist/parser/parser.js +201 -0
  31. package/dist/parser/types.d.ts +68 -0
  32. package/dist/parser/types.js +5 -0
  33. package/dist/query/builder.d.ts +61 -0
  34. package/dist/query/builder.js +188 -0
  35. package/dist/query/index.d.ts +2 -0
  36. package/dist/query/index.js +18 -0
  37. package/dist/query/types.d.ts +79 -0
  38. package/dist/query/types.js +2 -0
  39. package/dist/security/index.d.ts +2 -0
  40. package/dist/security/index.js +18 -0
  41. package/dist/security/types.d.ts +181 -0
  42. package/dist/security/types.js +43 -0
  43. package/dist/security/validator.d.ts +191 -0
  44. package/dist/security/validator.js +344 -0
  45. package/dist/translators/drizzle/index.d.ts +73 -0
  46. package/dist/translators/drizzle/index.js +260 -0
  47. package/dist/translators/index.d.ts +8 -0
  48. package/dist/translators/index.js +27 -0
  49. package/dist/translators/sql/index.d.ts +108 -0
  50. package/dist/translators/sql/index.js +252 -0
  51. package/dist/translators/types.d.ts +39 -0
  52. package/dist/translators/types.js +8 -0
  53. package/examples/qk-next/README.md +35 -0
  54. package/examples/qk-next/app/favicon.ico +0 -0
  55. package/examples/qk-next/app/globals.css +122 -0
  56. package/examples/qk-next/app/layout.tsx +121 -0
  57. package/examples/qk-next/app/page.tsx +813 -0
  58. package/examples/qk-next/app/providers.tsx +80 -0
  59. package/examples/qk-next/components/aurora-background.tsx +12 -0
  60. package/examples/qk-next/components/github-stars.tsx +51 -0
  61. package/examples/qk-next/components/mode-toggle.tsx +27 -0
  62. package/examples/qk-next/components/reactbits/blocks/Backgrounds/Aurora/Aurora.tsx +217 -0
  63. package/examples/qk-next/components/reactbits/blocks/Backgrounds/LightRays/LightRays.tsx +474 -0
  64. package/examples/qk-next/components/theme-provider.tsx +11 -0
  65. package/examples/qk-next/components/ui/card.tsx +92 -0
  66. package/examples/qk-next/components/ui/command.tsx +184 -0
  67. package/examples/qk-next/components/ui/dialog.tsx +143 -0
  68. package/examples/qk-next/components/ui/drawer.tsx +135 -0
  69. package/examples/qk-next/components/ui/hover-card.tsx +44 -0
  70. package/examples/qk-next/components/ui/icons.tsx +148 -0
  71. package/examples/qk-next/components/ui/sonner.tsx +26 -0
  72. package/examples/qk-next/components/ui/table.tsx +117 -0
  73. package/examples/qk-next/components.json +21 -0
  74. package/examples/qk-next/eslint.config.mjs +21 -0
  75. package/examples/qk-next/jsrepo.json +13 -0
  76. package/examples/qk-next/lib/utils.ts +6 -0
  77. package/examples/qk-next/next.config.ts +8 -0
  78. package/examples/qk-next/package.json +48 -0
  79. package/examples/qk-next/pnpm-lock.yaml +5558 -0
  80. package/examples/qk-next/postcss.config.mjs +5 -0
  81. package/examples/qk-next/public/file.svg +1 -0
  82. package/examples/qk-next/public/globe.svg +1 -0
  83. package/examples/qk-next/public/next.svg +1 -0
  84. package/examples/qk-next/public/vercel.svg +1 -0
  85. package/examples/qk-next/public/window.svg +1 -0
  86. package/examples/qk-next/tsconfig.json +42 -0
  87. package/examples/qk-next/types/sonner.d.ts +3 -0
  88. package/jest.config.js +26 -0
  89. package/package.json +51 -0
  90. package/src/adapters/drizzle/drizzle-adapter.test.ts +115 -0
  91. package/src/adapters/drizzle/index.ts +299 -0
  92. package/src/adapters/index.ts +11 -0
  93. package/src/adapters/types.ts +72 -0
  94. package/src/index.ts +194 -0
  95. package/src/integration.test.ts +202 -0
  96. package/src/parser/index.ts +2 -0
  97. package/src/parser/parser.test.ts +1056 -0
  98. package/src/parser/parser.ts +268 -0
  99. package/src/parser/types.ts +97 -0
  100. package/src/query/builder.test.ts +272 -0
  101. package/src/query/builder.ts +274 -0
  102. package/src/query/index.ts +2 -0
  103. package/src/query/types.ts +107 -0
  104. package/src/security/index.ts +2 -0
  105. package/src/security/types.ts +210 -0
  106. package/src/security/validator.test.ts +459 -0
  107. package/src/security/validator.ts +395 -0
  108. package/src/security.test.ts +366 -0
  109. package/src/translators/drizzle/drizzle-translator.test.ts +128 -0
  110. package/src/translators/drizzle/index.test.ts +45 -0
  111. package/src/translators/drizzle/index.ts +346 -0
  112. package/src/translators/index.ts +14 -0
  113. package/src/translators/sql/index.test.ts +45 -0
  114. package/src/translators/sql/index.ts +331 -0
  115. package/src/translators/sql/sql-translator.test.ts +419 -0
  116. package/src/translators/types.ts +44 -0
  117. package/src/types/sonner.d.ts +3 -0
  118. package/tsconfig.json +34 -0
@@ -0,0 +1,813 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState, useCallback, useRef, JSX } from 'react';
4
+ import { drizzle } from 'drizzle-orm/pglite';
5
+ import { usePGlite } from '@electric-sql/pglite-react';
6
+ import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core';
7
+ import { InferSelectModel, sql, SQLWrapper } from 'drizzle-orm';
8
+ import { Card, CardContent } from '@/components/ui/card';
9
+ import {
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow
16
+ } from '@/components/ui/table';
17
+ import {
18
+ QueryParser,
19
+ SqlTranslator,
20
+ DrizzleAdapter,
21
+ createQueryKit,
22
+ IDrizzleAdapterOptions
23
+ } from '@gblikas/querykit';
24
+ import { Copy, Check, Search, SearchCode, ChevronUp } from 'lucide-react';
25
+ import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
26
+ import { atomOneDarkReasonable } from 'react-syntax-highlighter/dist/esm/styles/hljs';
27
+ import sqlLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
28
+ import jsonLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/json';
29
+ import { toast } from 'sonner';
30
+ import Aurora from '@/components/reactbits/blocks/Backgrounds/Aurora/Aurora';
31
+ import { PGlite } from '@electric-sql/pglite';
32
+
33
+ // Simple GitHub-like highlighter for key:value tokens that colors only the value
34
+ const escapeHtml = (input: string): string =>
35
+ input
36
+ .replace(/&/g, '&')
37
+ .replace(/</g, '&lt;')
38
+ .replace(/>/g, '&gt;')
39
+ .replace(/"/g, '&quot;')
40
+ .replace(/'/g, '&#39;');
41
+
42
+ const highlightQueryHtml = (input: string): string => {
43
+ if (!input) return '';
44
+ const parts = input.split(/(\s+)/); // keep spaces
45
+ return parts
46
+ .map(part => {
47
+ if (/^\s+$/.test(part)) return part; // preserve spaces, rely on whitespace-pre-wrap
48
+ const idx = part.indexOf(':');
49
+ if (idx > 0) {
50
+ const key = escapeHtml(part.slice(0, idx));
51
+ const value = escapeHtml(part.slice(idx + 1));
52
+ // Note: no horizontal padding to keep overlay width identical to input text for caret alignment
53
+ return `${key}:<span class="text-blue-400 bg-blue-500/20 rounded">${value}</span>`;
54
+ }
55
+ return escapeHtml(part);
56
+ })
57
+ .join('');
58
+ };
59
+
60
+ const tasks = pgTable('tasks', {
61
+ id: serial('id').primaryKey(),
62
+ title: text('title').notNull(),
63
+ status: text('status').notNull(),
64
+ priority: integer('priority').default(0).notNull(),
65
+ completed: boolean('completed').default(false).notNull()
66
+ });
67
+
68
+ type Task = InferSelectModel<typeof tasks>;
69
+
70
+ export default function Home(): JSX.Element {
71
+ const [query, setQuery] = useState('');
72
+ const [results, setResults] = useState<Task[]>([]);
73
+ const [dbReady, setDbReady] = useState(false);
74
+ const [isSearching, setIsSearching] = useState(false);
75
+ const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false);
76
+ const [isInputFocused, setIsInputFocused] = useState(false);
77
+ const inputRef = useRef<HTMLInputElement | null>(null);
78
+ const [activeSuggestionIndex, setActiveSuggestionIndex] =
79
+ useState<number>(-1);
80
+ const [lastExecutedQuery, setLastExecutedQuery] = useState('');
81
+ const [generatedSQL, setGeneratedSQL] = useState('SELECT * FROM tasks');
82
+ const [, setLastExecutionMs] = useState<number | null>(null);
83
+ const [rowsScanned, setRowsScanned] = useState<number | null>(null);
84
+ const [operatorsUsed, setOperatorsUsed] = useState<string[]>([]);
85
+ const [usedQueryKit, setUsedQueryKit] = useState<boolean>(false);
86
+ const [explainJson, setExplainJson] = useState<string | null>(null);
87
+ const [, setPlanningTimeMs] = useState<number | null>(null);
88
+ const [, setExecutionTimeMs] = useState<number | null>(null);
89
+ const [explainError, setExplainError] = useState<string | null>(null);
90
+ const [dbExecutionMs, setDbExecutionMs] = useState<number | null>(null);
91
+ const [, setParseTranslateMs] = useState<number | null>(null);
92
+ const [, setExplainLatencyMs] = useState<number | null>(null);
93
+ const [, setBaselineFetchMs] = useState<number | null>(null);
94
+ const [hasCopiedSql, setHasCopiedSql] = useState(false);
95
+ const [showExplain, setShowExplain] = useState(false);
96
+ const [isResultsOpen, setIsResultsOpen] = useState(false);
97
+ const sqlCardAnchorRef = useRef<HTMLDivElement | null>(null);
98
+ const [drawerTopPx, setDrawerTopPx] = useState<number>(0);
99
+ const COPY_FEEDBACK_MS = 2000;
100
+
101
+ // Register languages once
102
+ useEffect(() => {
103
+ try {
104
+ (
105
+ SyntaxHighlighter as unknown as typeof SyntaxHighlighter
106
+ ).registerLanguage('sql', sqlLanguage);
107
+ (
108
+ SyntaxHighlighter as unknown as typeof SyntaxHighlighter
109
+ ).registerLanguage('json', jsonLanguage);
110
+ } catch (error) {
111
+ console.error('Failed to register languages:', error);
112
+ }
113
+ }, []);
114
+
115
+ // Pretty-print SQL by placing major clauses and boolean operators on new lines
116
+ const formatSqlForDisplay = useCallback((sqlText: string): string => {
117
+ if (!sqlText) return '';
118
+ let compact = sqlText.replace(/\s+/g, ' ').trim();
119
+ const clauseKeywords = [
120
+ 'FROM',
121
+ 'WHERE',
122
+ 'GROUP BY',
123
+ 'HAVING',
124
+ 'ORDER BY',
125
+ 'LIMIT',
126
+ 'OFFSET'
127
+ ];
128
+ for (const kw of clauseKeywords) {
129
+ const re = new RegExp(`\\s+${kw}\\b`, 'gi');
130
+ compact = compact.replace(re, `\n${kw}`);
131
+ }
132
+ compact = compact.replace(
133
+ /\s+(LEFT|RIGHT|FULL|INNER|CROSS)?\s*JOIN\b/gi,
134
+ match => `\n${match.trim().toUpperCase()}`
135
+ );
136
+ compact = compact.replace(/\s+ON\b/gi, '\nON');
137
+ compact = compact.replace(/\s+(AND|OR)\s+/gi, '\n $1 ');
138
+ return compact;
139
+ }, []);
140
+
141
+ const formattedSQL = useMemo(
142
+ () => formatSqlForDisplay(generatedSQL),
143
+ [generatedSQL, formatSqlForDisplay]
144
+ );
145
+
146
+ // Static suggestions for keyboard navigation
147
+ const suggestions = useMemo(
148
+ () => [
149
+ { q: 'status:done', desc: 'Find completed tasks' },
150
+ { q: 'priority:>=2', desc: 'High priority tasks' },
151
+ { q: 'status:doing AND priority:<3', desc: 'In-progress, low priority' },
152
+ { q: 'title:docs OR title:ship', desc: 'Documentation or shipping' },
153
+ { q: 'NOT completed:true', desc: 'Incomplete tasks' }
154
+ ],
155
+ []
156
+ );
157
+
158
+ // Use the PGlite instance from context
159
+ const pglite = usePGlite();
160
+ const db = useMemo(() => drizzle(pglite as unknown as PGlite), [pglite]);
161
+
162
+ useEffect(() => {
163
+ const seed = async (): Promise<void> => {
164
+ try {
165
+ console.log('Seeding database...');
166
+ await db.execute(sql`
167
+ create table if not exists tasks (
168
+ id serial primary key,
169
+ title text not null,
170
+ status text not null,
171
+ priority integer not null default 0,
172
+ completed boolean not null default false
173
+ );
174
+ `);
175
+ const existing = await db.select().from(tasks).limit(1);
176
+ if (existing.length === 0) {
177
+ await db.insert(tasks).values([
178
+ {
179
+ title: 'Write docs',
180
+ status: 'todo',
181
+ priority: 2,
182
+ completed: false
183
+ },
184
+ {
185
+ title: 'Ship alpha',
186
+ status: 'doing',
187
+ priority: 1,
188
+ completed: false
189
+ },
190
+ {
191
+ title: 'Fix bugs',
192
+ status: 'doing',
193
+ priority: 3,
194
+ completed: false
195
+ },
196
+ { title: 'Publish', status: 'done', priority: 1, completed: true }
197
+ ]);
198
+ }
199
+ console.log('Database seeded successfully');
200
+
201
+ // Load initial data and show default query details
202
+ const data = await db.select().from(tasks);
203
+ setResults(data as Task[]);
204
+ setLastExecutedQuery('(default)');
205
+ setGeneratedSQL('SELECT * FROM tasks');
206
+ setDbReady(true);
207
+ } catch (error) {
208
+ console.error('Database seeding failed:', error);
209
+ }
210
+ };
211
+ void seed();
212
+ }, [db]);
213
+
214
+ const parser = useMemo(() => new QueryParser(), []);
215
+ const sqlTranslator = useMemo(
216
+ () => new SqlTranslator({ useParameters: false }),
217
+ []
218
+ );
219
+ const qk = useMemo(() => {
220
+ const adapter = new DrizzleAdapter();
221
+ const iDrizzleAdataperOptions: IDrizzleAdapterOptions = {
222
+ db: db as unknown as PGlite,
223
+ schema: { tasks } as unknown as Record<string, Record<string, SQLWrapper>>
224
+ };
225
+ adapter.initialize(iDrizzleAdataperOptions);
226
+ return createQueryKit({
227
+ adapter,
228
+ schema: { tasks } as unknown as Record<string, Record<string, SQLWrapper>>
229
+ });
230
+ }, [db]);
231
+
232
+ // Note: Execute via QueryKit fluent API (Drizzle adapter under the hood)
233
+
234
+ // Execute search function
235
+ const executeSearch = useCallback(
236
+ async (searchQuery: string) => {
237
+ if (!dbReady) return;
238
+
239
+ setIsSearching(true);
240
+ setQuery(searchQuery); // Keep the query in the input field
241
+ setIsSuggestionsOpen(false);
242
+ setActiveSuggestionIndex(-1);
243
+ setIsInputFocused(false);
244
+ inputRef.current?.blur();
245
+ setOperatorsUsed([]);
246
+ setExplainJson(null);
247
+ setPlanningTimeMs(null);
248
+ setExecutionTimeMs(null);
249
+ setExplainError(null);
250
+ setDbExecutionMs(null);
251
+ setParseTranslateMs(null);
252
+ setExplainLatencyMs(null);
253
+ setBaselineFetchMs(null);
254
+ const started = performance.now();
255
+
256
+ try {
257
+ // Get all tasks count/base for messaging
258
+ const baselineStart = performance.now();
259
+ const allTasks = await db.select().from(tasks);
260
+ setBaselineFetchMs(performance.now() - baselineStart);
261
+ setRowsScanned(allTasks.length);
262
+
263
+ // Use QueryKit fluent API to execute the query
264
+ let filteredTasks: Task[] = allTasks as Task[];
265
+ let wasQueryKitUsed = false;
266
+ if (searchQuery.trim()) {
267
+ try {
268
+ filteredTasks = (await qk
269
+ .query('tasks')
270
+ .where(searchQuery)
271
+ .execute()) as Task[];
272
+ wasQueryKitUsed = true;
273
+ } catch (error) {
274
+ console.warn(
275
+ 'Query execution failed, falling back to simple search:',
276
+ error
277
+ );
278
+ const searchTerm = searchQuery.toLowerCase();
279
+ filteredTasks = (allTasks as Task[]).filter(
280
+ task =>
281
+ task.title.toLowerCase().includes(searchTerm) ||
282
+ task.status.toLowerCase().includes(searchTerm)
283
+ );
284
+ }
285
+ }
286
+
287
+ // Generate SQL from QueryKit for display
288
+ let mockSQL = 'SELECT * FROM tasks';
289
+ let detectedOperators: string[] = [];
290
+ let whereSql: string | null = null;
291
+ if (searchQuery.trim()) {
292
+ try {
293
+ const parseStart = performance.now();
294
+ const ast = parser.parse(searchQuery);
295
+ const translated = sqlTranslator.translate(ast) as
296
+ | string
297
+ | { sql: string; params: unknown[] };
298
+ setParseTranslateMs(performance.now() - parseStart);
299
+ whereSql =
300
+ typeof translated === 'string' ? translated : translated.sql;
301
+ mockSQL += ` WHERE ${whereSql}`;
302
+ // Robust operator detection with word boundaries and precedence
303
+ const extractOperators = (sqlText: string): string[] => {
304
+ const found = new Set<string>();
305
+ const upper = sqlText.toUpperCase();
306
+ // Keyword operators (use word boundaries)
307
+ const keywordOps: Array<[string, RegExp]> = [
308
+ ['ILIKE', /\bILIKE\b/i],
309
+ ['LIKE', /\bLIKE\b/i],
310
+ ['AND', /\bAND\b/i],
311
+ ['OR', /\bOR\b/i],
312
+ ['NOT', /\bNOT\b/i],
313
+ ['IN', /\bIN\b/i],
314
+ ['BETWEEN', /\bBETWEEN\b/i]
315
+ ];
316
+ for (const [name, re] of keywordOps) {
317
+ if (re.test(sqlText)) found.add(name);
318
+ }
319
+ // Symbol operators: match longest first and remove before shorter matches
320
+ let temp = upper;
321
+ const consume = (re: RegExp, label: string): void => {
322
+ if (re.test(temp)) {
323
+ found.add(label);
324
+ temp = temp.replace(re, ' ');
325
+ }
326
+ };
327
+ consume(/>=/g, '>=');
328
+ consume(/<=/g, '<=');
329
+ consume(/!=/g, '!=');
330
+ consume(/=/g, '=');
331
+ consume(/>/g, '>');
332
+ consume(/</g, '<');
333
+ return Array.from(found);
334
+ };
335
+ detectedOperators = extractOperators(whereSql);
336
+ } catch {
337
+ mockSQL += ` WHERE title ILIKE '%${searchQuery}%' OR status ILIKE '%${searchQuery}%'`;
338
+ detectedOperators = ['ILIKE'];
339
+ }
340
+ }
341
+
342
+ setResults(filteredTasks);
343
+ setLastExecutedQuery(searchQuery.trim() ? searchQuery : '(default)');
344
+ setGeneratedSQL(mockSQL);
345
+ setUsedQueryKit(wasQueryKitUsed);
346
+ setOperatorsUsed(Array.from(new Set(detectedOperators)));
347
+
348
+ // Try to run EXPLAIN ANALYZE to capture a plan (JSON format for easy parsing)
349
+ try {
350
+ const fullSql = mockSQL;
351
+ // Drizzle execute with raw SQL. PGlite should support EXPLAIN.
352
+ const explainCmd = `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${fullSql}`;
353
+ const explainStart = performance.now();
354
+ const explainRows = await db.execute(sql.raw(explainCmd));
355
+ setExplainLatencyMs(performance.now() - explainStart);
356
+ // Shape can vary. Try common Postgres JSON format: [{ "QUERY PLAN": [ { Plan: {...}, Planning Time: n, Execution Time: n } ] }]
357
+ const firstRow = Array.isArray(explainRows)
358
+ ? explainRows[0]
359
+ : (explainRows?.rows?.[0] ?? explainRows);
360
+ const planContainer =
361
+ firstRow?.['QUERY PLAN'] ?? firstRow?.query_plan ?? firstRow;
362
+ const jsonRoot = Array.isArray(planContainer)
363
+ ? planContainer[0]
364
+ : planContainer;
365
+ if (jsonRoot) {
366
+ const planning =
367
+ typeof jsonRoot['Planning Time'] === 'number'
368
+ ? jsonRoot['Planning Time']
369
+ : (jsonRoot?.PlanningTime ?? null);
370
+ const execution =
371
+ typeof jsonRoot['Execution Time'] === 'number'
372
+ ? jsonRoot['Execution Time']
373
+ : (jsonRoot?.ExecutionTime ?? null);
374
+ if (typeof planning === 'number') setPlanningTimeMs(planning);
375
+ if (typeof execution === 'number') setExecutionTimeMs(execution);
376
+ if (typeof execution === 'number') setDbExecutionMs(execution);
377
+ setExplainJson(JSON.stringify(jsonRoot, null, 2));
378
+ } else {
379
+ // Some drivers return text rows when FORMAT JSON is not supported
380
+ const textPlan =
381
+ firstRow?.['QUERY PLAN'] ??
382
+ firstRow?.explain ??
383
+ String(explainRows ?? '');
384
+ setExplainJson(
385
+ typeof textPlan === 'string'
386
+ ? textPlan
387
+ : JSON.stringify(textPlan, null, 2)
388
+ );
389
+ }
390
+ } catch {
391
+ setExplainError('EXPLAIN not available');
392
+ }
393
+
394
+ const elapsed = performance.now() - started;
395
+ setLastExecutionMs(elapsed);
396
+
397
+ if (searchQuery.trim()) {
398
+ toast(
399
+ `QueryKit parsed and filtered ${allTasks.length} rows → ${filteredTasks.length} results in ${elapsed.toFixed(1)} ms`
400
+ );
401
+ } else {
402
+ toast(`Showing all ${allTasks.length} rows`);
403
+ }
404
+ } catch (error) {
405
+ console.error('Search failed:', error);
406
+ toast('Search failed');
407
+ } finally {
408
+ setIsSearching(false);
409
+ }
410
+ },
411
+ [dbReady, db, parser, sqlTranslator, qk]
412
+ );
413
+
414
+ // Handle input change (just updates query state)
415
+ const handleSearchChange = useCallback(
416
+ (value: string) => {
417
+ setQuery(value);
418
+ setIsSuggestionsOpen(true);
419
+ if (!isInputFocused) setIsInputFocused(true);
420
+ // Do not auto-select a suggestion when empty; allow user to choose via arrows
421
+ setActiveSuggestionIndex(-1);
422
+ },
423
+ [isInputFocused]
424
+ );
425
+
426
+ // Handle Enter key press to execute search
427
+ const handleKeyDown = useCallback(
428
+ (event: React.KeyboardEvent) => {
429
+ if (event.key === 'Escape') {
430
+ event.preventDefault();
431
+ setIsSuggestionsOpen(false);
432
+ setActiveSuggestionIndex(-1);
433
+ setIsInputFocused(false);
434
+ inputRef.current?.blur();
435
+ return;
436
+ }
437
+ if (event.key === 'ArrowDown') {
438
+ if (!isSuggestionsOpen) return;
439
+ event.preventDefault();
440
+ setActiveSuggestionIndex(prev => {
441
+ const next = prev < suggestions.length - 1 ? prev + 1 : 0;
442
+ return next;
443
+ });
444
+ return;
445
+ }
446
+ if (event.key === 'ArrowUp') {
447
+ if (!isSuggestionsOpen) return;
448
+ event.preventDefault();
449
+ setActiveSuggestionIndex(prev => {
450
+ const next = prev > 0 ? prev - 1 : suggestions.length - 1;
451
+ return next;
452
+ });
453
+ return;
454
+ }
455
+ if (event.key === 'Enter') {
456
+ event.preventDefault();
457
+ const trimmed = query.trim();
458
+ if (trimmed.length === 0) {
459
+ // Clear to default dataset
460
+ setIsSuggestionsOpen(false);
461
+ setActiveSuggestionIndex(-1);
462
+ void executeSearch('');
463
+ return;
464
+ }
465
+ if (isSuggestionsOpen && activeSuggestionIndex >= 0) {
466
+ const chosen = suggestions[activeSuggestionIndex]?.q ?? query;
467
+ setIsSuggestionsOpen(false);
468
+ setActiveSuggestionIndex(-1);
469
+ void executeSearch(chosen);
470
+ } else {
471
+ setIsSuggestionsOpen(false);
472
+ void executeSearch(trimmed);
473
+ }
474
+ return;
475
+ }
476
+ },
477
+ [
478
+ query,
479
+ executeSearch,
480
+ isSuggestionsOpen,
481
+ activeSuggestionIndex,
482
+ suggestions
483
+ ]
484
+ );
485
+
486
+ // Copy SQL output
487
+ const handleCopySql = useCallback(async () => {
488
+ try {
489
+ const textToCopy = showExplain
490
+ ? (explainJson ?? 'EXPLAIN not available')
491
+ : formattedSQL || generatedSQL;
492
+ await navigator.clipboard.writeText(textToCopy);
493
+ toast(
494
+ showExplain ? 'EXPLAIN copied to clipboard' : 'SQL copied to clipboard'
495
+ );
496
+ setHasCopiedSql(true);
497
+ window.setTimeout(() => setHasCopiedSql(false), COPY_FEEDBACK_MS);
498
+ } catch {
499
+ toast(showExplain ? 'Failed to copy EXPLAIN' : 'Failed to copy SQL');
500
+ }
501
+ }, [formattedSQL, generatedSQL, explainJson, showExplain]);
502
+
503
+ // Add Cmd+K keyboard shortcut to focus the inline input
504
+ useEffect(() => {
505
+ const down = (e: KeyboardEvent): void => {
506
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
507
+ e.preventDefault();
508
+ inputRef.current?.focus();
509
+ }
510
+ };
511
+
512
+ document.addEventListener('keydown', down);
513
+ return (): void => document.removeEventListener('keydown', down);
514
+ }, []);
515
+
516
+ // Measure the top of the SQL card to set drawer height (covers up to the card's top)
517
+ useEffect(() => {
518
+ const measure = (): void => {
519
+ const el = sqlCardAnchorRef.current;
520
+ if (!el) return;
521
+ const rect = el.getBoundingClientRect();
522
+ const top = Math.max(0, rect.top);
523
+ setDrawerTopPx(top);
524
+ };
525
+ const onResize = (): number => requestAnimationFrame(measure);
526
+ measure();
527
+ window.addEventListener('resize', onResize);
528
+ return (): void => window.removeEventListener('resize', onResize);
529
+ }, [lastExecutedQuery]);
530
+
531
+ return (
532
+ <div className="relative min-h-[100svh] h-[100svh] flex flex-col items-center justify-start px-6 bg-transparent overflow-hidden pt-[10svh] sm:pt-[33svh]">
533
+ {/* Aurora background */}
534
+ <div className="fixed inset-0 -z-10 pointer-events-none">
535
+ <Aurora amplitude={1.0} blend={0.6} speed={0.4} />
536
+ </div>
537
+ {/* {isInputFocused && (
538
+ <div className="fixed inset-0 z-[5] pointer-events-none">
539
+ <LightRays
540
+ rayLength={3}
541
+ saturation={2}
542
+ lightSpread={.5}
543
+ raysColor="#fff"
544
+ />
545
+ </div>
546
+ )} */}
547
+ <div className="fixed inset-0 z-10 bg-background/60 transition-opacity pointer-events-none" />
548
+ <div className="relative z-20 w-full max-w-3xl space-y-6">
549
+ <div className="text-center mb-2">
550
+ <h1 className="text-4xl sm:text-5xl font-extrabold tracking-tight">
551
+ QueryKit
552
+ </h1>
553
+ <p className="text-base sm:text-lg text-muted-foreground mt-2">
554
+ Try filtering tasks with the QueryKit DSL. Press ⌘K to start.
555
+ </p>
556
+ </div>
557
+ {/* Inline search input with recommendation popover */}
558
+ <div className="relative z-50 w-full">
559
+ <div className="flex items-center gap-2 rounded-2xl border bg-background shadow-sm px-3 py-2">
560
+ <Search className="h-4 w-4 text-muted-foreground" />
561
+ <div className="relative w-full">
562
+ {/* Highlight overlay behind the input */}
563
+ {query && (
564
+ <div
565
+ aria-hidden
566
+ className="pointer-events-none absolute inset-0 z-0 whitespace-pre text-sm leading-[1.25rem] text-foreground/90 flex items-center"
567
+ dangerouslySetInnerHTML={{
568
+ __html: highlightQueryHtml(query)
569
+ }}
570
+ />
571
+ )}
572
+ <input
573
+ ref={inputRef}
574
+ type="text"
575
+ value={query}
576
+ onChange={e => handleSearchChange(e.target.value)}
577
+ onKeyDown={handleKeyDown}
578
+ onFocus={() => {
579
+ setIsInputFocused(true);
580
+ setIsSuggestionsOpen(true);
581
+ }}
582
+ onBlur={e => {
583
+ const target = e.currentTarget;
584
+ // Defer blur closing to allow click on suggestions
585
+ setTimeout(() => {
586
+ const nextActive =
587
+ document.activeElement as HTMLElement | null;
588
+ if (
589
+ !target ||
590
+ !nextActive ||
591
+ !target.contains(nextActive)
592
+ ) {
593
+ setIsInputFocused(false);
594
+ setIsSuggestionsOpen(false);
595
+ }
596
+ }, 0);
597
+ }}
598
+ placeholder="Search tasks with QueryKit (Press Enter to search)"
599
+ className={`placeholder:text-muted-foreground relative z-10 flex h-10 w-full rounded-md bg-transparent text-sm leading-[1.25rem] outline-none ${query ? 'text-transparent caret-foreground' : ''}`}
600
+ />
601
+ </div>
602
+ <kbd className="bg-muted text-muted-foreground pointer-events-none inline-flex h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none">
603
+ <span className="text-xs">⌘</span>K
604
+ </kbd>
605
+ </div>
606
+ {/* Simple recommendations popover */}
607
+ {isSuggestionsOpen && (
608
+ <div className="absolute left-0 right-0 z-[60] mt-2 rounded-md border bg-popover text-popover-foreground shadow-md">
609
+ <div className="px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground border-b">
610
+ QueryKit Examples
611
+ </div>
612
+ <ul
613
+ className="max-h-72 overflow-y-auto py-1"
614
+ role="listbox"
615
+ aria-label="Query suggestions"
616
+ >
617
+ {suggestions.map((s, idx) => (
618
+ <li
619
+ key={s.q}
620
+ role="option"
621
+ aria-selected={activeSuggestionIndex === idx}
622
+ className={`flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent ${activeSuggestionIndex === idx ? 'bg-accent' : ''}`}
623
+ onMouseDown={e => e.preventDefault()}
624
+ onMouseEnter={() => setActiveSuggestionIndex(idx)}
625
+ onClick={() => {
626
+ setIsSuggestionsOpen(false);
627
+ setActiveSuggestionIndex(-1);
628
+ executeSearch(s.q);
629
+ }}
630
+ >
631
+ <span
632
+ className="text-sm"
633
+ dangerouslySetInnerHTML={{
634
+ __html: highlightQueryHtml(s.q)
635
+ }}
636
+ />
637
+ <span className="ml-auto text-xs text-muted-foreground">
638
+ {s.desc}
639
+ </span>
640
+ </li>
641
+ ))}
642
+ </ul>
643
+ </div>
644
+ )}
645
+ </div>
646
+
647
+ {/* Query Card (no header) */}
648
+ <div ref={sqlCardAnchorRef} className="w-full">
649
+ <Card className="w-full">
650
+ {lastExecutedQuery && (
651
+ <CardContent>
652
+ <div className="relative bg-muted p-3 rounded-md">
653
+ <button
654
+ type="button"
655
+ onClick={() => setShowExplain(v => !v)}
656
+ aria-label={
657
+ showExplain ? 'Show SQL' : 'Show EXPLAIN ANALYZE'
658
+ }
659
+ title={showExplain ? 'Show SQL' : 'Show EXPLAIN ANALYZE'}
660
+ className={`absolute top-2 right-10 z-10 inline-flex items-center justify-center rounded-md hover:bg-accent/30 transition-colors h-7 w-7 ${showExplain ? 'bg-accent/40' : ''}`}
661
+ >
662
+ <SearchCode className="h-4 w-4" />
663
+ </button>
664
+ <button
665
+ type="button"
666
+ onClick={handleCopySql}
667
+ aria-label={showExplain ? 'Copy EXPLAIN' : 'Copy SQL'}
668
+ title={showExplain ? 'Copy EXPLAIN' : 'Copy SQL'}
669
+ className="absolute top-2 right-2 z-10 inline-flex items-center justify-center rounded-md hover:bg-accent/30 transition-colors h-7 w-7"
670
+ >
671
+ <Copy
672
+ className={`h-4 w-4 transition-all duration-200 ${hasCopiedSql ? 'opacity-0 scale-90' : 'opacity-100 scale-100'}`}
673
+ />
674
+ <Check
675
+ className={`h-4 w-4 absolute transition-all duration-200 ${hasCopiedSql ? 'opacity-100 scale-100' : 'opacity-0 scale-110'}`}
676
+ />
677
+ </button>
678
+ <SyntaxHighlighter
679
+ language={showExplain ? 'json' : 'sql'}
680
+ style={atomOneDarkReasonable}
681
+ customStyle={{ background: 'transparent', margin: 0 }}
682
+ wrapLongLines
683
+ >
684
+ {showExplain
685
+ ? (explainJson ?? explainError ?? 'EXPLAIN not available')
686
+ : formattedSQL}
687
+ </SyntaxHighlighter>
688
+ </div>
689
+ <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
690
+ <div className="rounded-md border p-3 bg-background/50">
691
+ <div className="text-xs text-muted-foreground">
692
+ DB time (EXPLAIN)
693
+ </div>
694
+ <div className="mt-1 text-base font-medium">
695
+ {dbExecutionMs !== null
696
+ ? `${dbExecutionMs.toFixed(3)} ms`
697
+ : '-'}
698
+ </div>
699
+ </div>
700
+ <div className="rounded-md border p-3 bg-background/50">
701
+ <div className="text-xs text-muted-foreground">
702
+ Rows returned
703
+ </div>
704
+ <div className="mt-1 text-base font-medium">{`${results.length} of ${rowsScanned ?? '-'}`}</div>
705
+ </div>
706
+ <div className="rounded-md border p-3 bg-background/50">
707
+ <div className="text-xs text-muted-foreground">Engine</div>
708
+ <div className="mt-1">
709
+ <span className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-muted">
710
+ {usedQueryKit
711
+ ? 'QueryKit · Drizzle'
712
+ : 'Client fallback'}
713
+ </span>
714
+ </div>
715
+ </div>
716
+ </div>
717
+ <div className="mt-3">
718
+ <div className="text-xs text-muted-foreground mb-1">
719
+ Detected operators
720
+ </div>
721
+ {operatorsUsed.length ? (
722
+ <div className="flex flex-wrap gap-2">
723
+ {operatorsUsed.map(op => (
724
+ <span
725
+ key={op}
726
+ className="inline-flex items-center rounded-full border bg-muted px-2 py-0.5 text-xs font-medium"
727
+ >
728
+ {op}
729
+ </span>
730
+ ))}
731
+ </div>
732
+ ) : (
733
+ <div className="text-xs text-muted-foreground">-</div>
734
+ )}
735
+ </div>
736
+ {/* EXPLAIN has been integrated into the SQL window via the SearchCode toggle */}
737
+ </CardContent>
738
+ )}
739
+ </Card>
740
+ </div>
741
+ </div>
742
+
743
+ {/* Toggle button to open results drawer */}
744
+ <button
745
+ type="button"
746
+ onClick={() => setIsResultsOpen(v => !v)}
747
+ aria-expanded={isResultsOpen}
748
+ aria-controls="results-drawer"
749
+ title={isResultsOpen ? 'Hide results' : 'Show results'}
750
+ className="fixed bottom-10 left-1/2 -translate-x-1/2 z-[70] inline-flex items-center justify-center rounded-full border bg-background shadow px-3 py-2 text-xs hover:bg-accent transition-colors"
751
+ >
752
+ <ChevronUp
753
+ className={`h-4 w-4 transition-transform ${isResultsOpen ? 'rotate-180' : ''}`}
754
+ />
755
+ <span className="ml-2">{isResultsOpen ? 'Hide' : 'Results'}</span>
756
+ </button>
757
+
758
+ {/* Bottom drawer with results table */}
759
+ {isResultsOpen && (
760
+ <div
761
+ className="fixed inset-0 z-[55]"
762
+ onClick={() => setIsResultsOpen(false)}
763
+ />
764
+ )}
765
+
766
+ <div
767
+ id="results-drawer"
768
+ role="dialog"
769
+ aria-label="Query results"
770
+ className={`fixed left-0 right-0 z-[60] transition-transform duration-300 ease-out ${isResultsOpen ? 'translate-y-0' : 'translate-y-full'}`}
771
+ style={{
772
+ pointerEvents: isResultsOpen ? 'auto' : 'none',
773
+ top: `${drawerTopPx}px`,
774
+ bottom: '12px'
775
+ }}
776
+ >
777
+ <div
778
+ className="mx-auto w-full max-w-3xl rounded-2xl border bg-background shadow-xl flex flex-col"
779
+ style={{ height: `calc(100svh - ${drawerTopPx}px - 12px)` }}
780
+ >
781
+ <div className="p-3 border-b flex items-center justify-between shrink-0">
782
+ <div className="text-sm font-medium">Results</div>
783
+ <div className="text-xs text-muted-foreground">
784
+ {isSearching ? 'Searching…' : `${results.length} rows`}
785
+ </div>
786
+ </div>
787
+ <div className="p-3 overflow-auto flex-1">
788
+ <Table>
789
+ <TableHeader>
790
+ <TableRow>
791
+ <TableHead className="w-[80px]">ID</TableHead>
792
+ <TableHead>Title</TableHead>
793
+ <TableHead>Status</TableHead>
794
+ <TableHead className="text-right">Priority</TableHead>
795
+ </TableRow>
796
+ </TableHeader>
797
+ <TableBody>
798
+ {results.map(t => (
799
+ <TableRow key={t.id}>
800
+ <TableCell>{t.id}</TableCell>
801
+ <TableCell>{t.title}</TableCell>
802
+ <TableCell>{t.status}</TableCell>
803
+ <TableCell className="text-right">{t.priority}</TableCell>
804
+ </TableRow>
805
+ ))}
806
+ </TableBody>
807
+ </Table>
808
+ </div>
809
+ </div>
810
+ </div>
811
+ </div>
812
+ );
813
+ }