AppView in a box as a Vite plugin thing hatk.dev
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: move admin SQL to db layer for SQLite compat, remove schema page

Admin page failed on SQLite with "unrecognized token: ':''" due to
DuckDB-specific COUNT(*)::INTEGER and pragma_database_size() syntax.
Moved all inline SQL from admin routes into dialect-aware db.ts
functions. Removed unused schema endpoint and UI. Added resync
fallback via triggerAutoBackfill when onResync callback is not provided.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+83 -89
+1 -1
packages/hatk/package.json
··· 1 1 { 2 2 "name": "@hatk/hatk", 3 - "version": "0.0.1-alpha.35", 3 + "version": "0.0.1-alpha.36", 4 4 "license": "MIT", 5 5 "bin": { 6 6 "hatk": "dist/cli.js"
-54
packages/hatk/public/admin.html
··· 783 783 font-size: 1rem; 784 784 } 785 785 786 - /* ── Schema ── */ 787 - .schema-pre { 788 - font-family: var(--mono); 789 - font-size: 0.8rem; 790 - line-height: 1.6; 791 - padding: 1rem; 792 - margin: 0; 793 - background: var(--bg-recessed); 794 - border-radius: 0 0 6px 6px; 795 - white-space: pre-wrap; 796 - word-break: break-word; 797 - color: var(--text); 798 - overflow-x: auto; 799 - } 800 - .schema-section { 801 - margin-bottom: 1.5rem; 802 - } 803 786 .loading { 804 787 color: var(--text-3); 805 788 font-size: 0.9375rem; ··· 1220 1203 <button class="tab active" data-tab="overview">Overview</button> 1221 1204 <button class="tab" data-tab="repos">Repos</button> 1222 1205 <button class="tab" data-tab="content">Content</button> 1223 - <button class="tab" data-tab="schema">Schema</button> 1224 1206 </nav> 1225 1207 1226 1208 <!-- Overview --> ··· 1277 1259 <div id="repos-results"><div class="loading">Loading</div></div> 1278 1260 </div> 1279 1261 1280 - <!-- Schema --> 1281 - <div class="tab-panel" id="panel-schema"> 1282 - <div id="schema-results"><div class="loading">Loading</div></div> 1283 - </div> 1284 1262 1285 1263 <!-- Content --> 1286 1264 <div class="tab-panel" id="panel-content"> ··· 1307 1285 <button class="bnav-btn active" data-tab="overview">Overview</button> 1308 1286 <button class="bnav-btn" data-tab="repos">Repos</button> 1309 1287 <button class="bnav-btn" data-tab="content">Content</button> 1310 - <button class="bnav-btn" data-tab="schema">Schema</button> 1311 1288 </div> 1312 1289 </div> 1313 1290 </div> ··· 1490 1467 document.getElementById(`panel-${tab}`).classList.add('active') 1491 1468 if (tab === 'overview') loadOverview() 1492 1469 if (tab === 'repos') loadRepos() 1493 - if (tab === 'schema') loadSchema() 1494 1470 if (tab === 'content') loadContent() 1495 1471 if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' }) 1496 1472 } ··· 1598 1574 toast(e.message, 'error') 1599 1575 } 1600 1576 }) 1601 - 1602 - // ── Schema ── 1603 - 1604 - async function loadSchema() { 1605 - const container = document.getElementById('schema-results') 1606 - try { 1607 - const data = await api('/admin/schema') 1608 - let html = '' 1609 - 1610 - // Lexicons section 1611 - if (data.lexicons && data.lexicons.length) { 1612 - html += '<div class="schema-section"><div class="section-label">Lexicons</div>' 1613 - for (const lex of data.lexicons) { 1614 - html += `<div class="card" style="margin-bottom:0.5rem;"><div style="font-family:var(--mono);font-size:0.8rem;font-weight:600;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">${escapeHtml(lex.nsid)}</div><pre class="schema-pre">${escapeHtml(JSON.stringify(lex.lexicon, null, 2))}</pre></div>` 1615 - } 1616 - html += '</div>' 1617 - } 1618 - 1619 - // DDL section 1620 - if (data.ddl) { 1621 - html += '<div class="schema-section"><div class="section-label">Tables (DuckDB DDL)</div>' 1622 - html += `<div class="card"><pre class="schema-pre">${escapeHtml(data.ddl)}</pre></div>` 1623 - html += '</div>' 1624 - } 1625 - 1626 - container.innerHTML = html || '<div class="empty-state">No schema found</div>' 1627 - } catch (e) { 1628 - container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>` 1629 - } 1630 - } 1631 1577 1632 1578 // ── Repos ── 1633 1579
+58
packages/hatk/src/database/db.ts
··· 406 406 return rows.map((r: any) => r.did) 407 407 } 408 408 409 + export async function listActiveRepoDids(): Promise<string[]> { 410 + const rows = await all(`SELECT did FROM _repos WHERE status = 'active'`) 411 + return rows.map((r: any) => r.did) 412 + } 413 + 414 + export async function removeRepo(did: string): Promise<void> { 415 + await run(`DELETE FROM _repos WHERE did = $1`, [did]) 416 + } 417 + 418 + export async function getRepoHandle(did: string): Promise<string | null> { 419 + const rows = await all(`SELECT handle FROM _repos WHERE did = $1`, [did]) 420 + return rows[0]?.handle ?? null 421 + } 422 + 409 423 export async function listAllRepoStatuses(): Promise<Array<{ did: string; status: string }>> { 410 424 return (await all(`SELECT did, status FROM _repos`)) as Array<{ did: string; status: string }> 411 425 } ··· 453 467 counts[collection] = Number(rows[0]?.count || 0) 454 468 } 455 469 return counts 470 + } 471 + 472 + export async function getRepoStatusCounts(): Promise<Record<string, number>> { 473 + const rows = await all(`SELECT status, ${dialect.countAsInteger} as count FROM _repos GROUP BY status`) 474 + const counts: Record<string, number> = {} 475 + for (const row of rows) counts[row.status as string] = Number(row.count) 476 + return counts 477 + } 478 + 479 + export async function getDatabaseSize(): Promise<Record<string, string>> { 480 + if (dialect.supportsSequences) { 481 + // DuckDB: pragma_database_size returns pre-formatted strings 482 + const rows = await all('SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()') 483 + return (rows[0] as Record<string, string>) ?? {} 484 + } 485 + // SQLite: compute from page_count * page_size 486 + const pages = await all('SELECT page_count FROM pragma_page_count()') 487 + const sizes = await all('SELECT page_size FROM pragma_page_size()') 488 + const pageCount = Number(pages[0]?.page_count ?? 0) 489 + const pageSize = Number(sizes[0]?.page_size ?? 0) 490 + const bytes = pageCount * pageSize 491 + const mib = (bytes / 1024 / 1024).toFixed(1) 492 + return { database_size: `${mib} MiB`, memory_usage: 'N/A', memory_limit: 'N/A' } 493 + } 494 + 495 + export async function getLabelCount(val: string): Promise<number> { 496 + const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _labels WHERE val = $1`, [val]) 497 + return Number(rows[0]?.count || 0) 498 + } 499 + 500 + export async function deleteLabels(val: string): Promise<number> { 501 + const count = await getLabelCount(val) 502 + await run(`DELETE FROM _labels WHERE val = $1`, [val]) 503 + return count 504 + } 505 + 506 + export async function getRecentRecords(collection: string, limit: number): Promise<any[]> { 507 + const schema = schemas.get(collection) 508 + if (!schema) return [] 509 + const rows = await all( 510 + `SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at ORDER BY t.indexed_at DESC LIMIT $1`, 511 + [limit], 512 + ) 513 + return rows 456 514 } 457 515 458 516 export async function getSchemaDump(): Promise<string> {
+24 -34
packages/hatk/src/server.ts
··· 10 10 setRepoStatus, 11 11 getRepoStatus, 12 12 getRepoRetryInfo, 13 - querySQL, 14 13 queryLabelsForUris, 15 14 insertLabels, 16 15 searchAccounts, 17 16 listReposPaginated, 18 17 getCollectionCounts, 19 - getSchemaDump, 18 + getRepoStatusCounts, 19 + getDatabaseSize, 20 + deleteLabels, 21 + getRecentRecords, 22 + listActiveRepoDids, 23 + removeRepo, 24 + getRepoHandle, 20 25 getPreferences, 21 26 putPreference, 22 27 } from './database/db.ts' ··· 442 447 if (denied) return denied 443 448 const { val } = JSON.parse(await request.text()) 444 449 if (!val) return withCors(jsonError(400, 'Missing val', acceptEncoding)) 445 - const result = await querySQL(`SELECT COUNT(*)::INTEGER as count FROM _labels WHERE val = $1`, [val]) 446 - const count = Number(result[0]?.count || 0) 447 - await querySQL(`DELETE FROM _labels WHERE val = $1`, [val]) 448 - return withCors(json({ deleted: count }, 200, acceptEncoding)) 450 + const deleted = await deleteLabels(val) 451 + return withCors(json({ deleted }, 200, acceptEncoding)) 449 452 } 450 453 451 454 // POST /admin/labels/negate — negate a label ··· 497 500 const allResults: any[] = [] 498 501 for (const col of collections) { 499 502 try { 500 - const schema = getSchema(col) 501 - if (!schema) continue 502 - const rows = await querySQL( 503 - `SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at ORDER BY t.indexed_at DESC LIMIT $1`, 504 - [limit + offset], 505 - ) 503 + const rows = await getRecentRecords(col, limit + offset) 504 + if (!rows.length) continue 506 505 const uris = rows.map((r: any) => r.uri) 507 506 const labelsMap = await queryLabelsForUris(uris) 508 507 for (const rec of rows) { ··· 585 584 if (Array.isArray(dids) && dids.length > 0) { 586 585 repoList = dids 587 586 } else { 588 - const rows = await querySQL(`SELECT did FROM _repos WHERE status = 'active'`) 589 - repoList = rows.map((r: any) => r.did) 587 + repoList = await listActiveRepoDids() 590 588 } 591 589 for (const did of repoList) { 592 590 await setRepoStatus(did, 'pending') 593 591 } 594 - if (config.onResync) config.onResync() 592 + if (config.onResync) { 593 + config.onResync() 594 + } else { 595 + for (const did of repoList) { 596 + triggerAutoBackfill(did) 597 + } 598 + } 595 599 return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding)) 596 600 } 597 601 ··· 602 606 const { dids } = JSON.parse(await request.text()) 603 607 if (!Array.isArray(dids)) return withCors(jsonError(400, 'Missing dids array', acceptEncoding)) 604 608 for (const did of dids) { 605 - await querySQL(`DELETE FROM _repos WHERE did = $1`, [did]) 609 + await removeRepo(did) 606 610 } 607 611 return withCors(json({ removed: dids.length }, 200, acceptEncoding)) 608 612 } ··· 611 615 if (url.pathname === '/admin/info') { 612 616 const denied = requireAdmin(viewer, acceptEncoding) 613 617 if (denied) return denied 614 - const rows = await querySQL(`SELECT status, COUNT(*)::INTEGER as count FROM _repos GROUP BY status`) 615 - const counts: Record<string, number> = {} 616 - for (const row of rows) counts[row.status as string] = Number(row.count) 617 - const sizeRows = await querySQL(`SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()`) 618 - const dbInfo = sizeRows[0] ?? {} 618 + const counts = await getRepoStatusCounts() 619 + const dbInfo = await getDatabaseSize() 619 620 const collectionCounts = await getCollectionCounts() 620 621 const mem = process.memoryUsage() 621 622 const node = { ··· 663 664 return withCors(json(result, 200, acceptEncoding)) 664 665 } 665 666 666 - // GET /admin/schema — full DuckDB DDL dump + lexicons 667 - if (url.pathname === '/admin/schema') { 668 - const denied = requireAdmin(viewer, acceptEncoding) 669 - if (denied) return denied 670 - const { getAllLexicons } = await import('./database/schema.ts') 671 - const ddl = await getSchemaDump() 672 - return withCors(json({ ddl, lexicons: getAllLexicons() }, 200, acceptEncoding)) 673 - } 674 - 675 667 // ── Public Repo Endpoints (used by hatk clients for auto-sync) ── 676 668 677 669 // POST /repos/add — enqueue DIDs for backfill (public) ··· 725 717 if (url.pathname === '/__dev/login' && devMode && oauth) { 726 718 const did = url.searchParams.get('did') 727 719 if (!did) return withCors(jsonError(400, 'did required', acceptEncoding)) 728 - const handleRows = await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]) 729 - const handle = handleRows[0]?.handle ?? did 720 + const handle = await getRepoHandle(did) ?? did 730 721 const cookieValue = await createSessionCookie({ did, handle }) 731 722 const secure = url.protocol === 'https:' 732 723 return new Response(JSON.stringify({ ok: true }), { ··· 794 785 if (!code) return withCors(jsonError(400, 'Missing code', acceptEncoding)) 795 786 const result = await handleCallback(oauth, code, state, iss) 796 787 const isSecure = requestOrigin.startsWith('https') 797 - const handleRows = await querySQL('SELECT handle FROM _repos WHERE did = $1', [result.did]) 798 - const handle = handleRows[0]?.handle ?? result.did 788 + const handle = await getRepoHandle(result.did) ?? result.did 799 789 const cookie = await createSessionCookie({ did: result.did, handle }) 800 790 // Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params 801 791 const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri