seeds.tachi.ac

This commit is contained in:
zk
2026-04-21 23:25:54 +00:00
parent c157d0026a
commit ea52824a4b
75 changed files with 7936 additions and 247 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
start:
#!/bin/bash
ARGS=(server client)
ARGS=(server client seeds-webui)
echo "Starting: ${ARGS[@]}"
set -e
@@ -20,4 +20,7 @@ server:
bun --watch src/main.ts
client:
cd typescript/client && bun vite
cd typescript/client && bun vite
seeds-webui:
cd typescript/seeds-webui && bun vite
+76
View File
@@ -309,6 +309,42 @@
"tsconfig-paths": "catalog:",
},
},
"typescript/seeds-webui": {
"name": "tachi-seeds-webui",
"version": "0.1.0-see-project-root",
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@lezer/highlight": "^1.2.3",
"@sqlite.org/sqlite-wasm": "^3.51.2-build9",
"codemirror": "^6.0.2",
"comlink": "^4.4.2",
"fast-json-patch": "^3.1.1",
"nanoid": "catalog:",
"natural-compare": "catalog:",
"react": "catalog:",
"react-bootstrap": "catalog:",
"react-dom": "catalog:",
"react-query": "catalog:",
"react-router-dom": "catalog:",
"tachi-common": "workspace:*",
"zod": "catalog:",
},
"devDependencies": {
"@types/natural-compare": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-router-dom": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "catalog:",
"bootstrap-scss": "catalog:",
"eslint-config-tachi": "workspace:*",
"sass": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-html": "catalog:",
},
},
"typescript/server": {
"name": "tachi-server",
"version": "3.0.0",
@@ -780,6 +816,22 @@
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.11.0", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
"@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="],
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
"@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="],
"@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="],
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
"@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
"@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="],
"@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="],
@@ -936,6 +988,14 @@
"@kristiandupont/recase": ["@kristiandupont/recase@1.4.1", "", { "dependencies": { "ramda": "^0.30.0" } }, "sha512-e5t4YqhnRGbS9sU4N52cQgTn37qKwTsxDDcIuIkgPvX0UmnL+7eoOR6oFXeCib5zYuw03vYKpR55NBq+W43j1A=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
@@ -1182,6 +1242,8 @@
"@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="],
"@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.51.2-build9", "", {}, "sha512-tj2Sa6X4gVZqS9SiT2xlEeChSUklSGq8Hz6zKNbHW6NRiGyrSXiFcspnrWru1wPBT76RX/VxweESKxawCxfDxg=="],
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
@@ -1574,6 +1636,8 @@
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -1582,6 +1646,8 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
@@ -1620,6 +1686,8 @@
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cron-parser": ["cron-parser@2.18.0", "", { "dependencies": { "is-nan": "^1.3.0", "moment-timezone": "^0.5.31" } }, "sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1906,6 +1974,8 @@
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="],
"fast-json-stable-hash": ["fast-json-stable-hash@1.0.3", "", {}, "sha512-iLK3Tq2J/KbiUn/5OHyhxikHqzwFXSM2e803Dp2JtBdQjsCVuZwWvL+WqWXPPRUEuDX4c0kqID1SU84RCfxs3A=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -2816,6 +2886,8 @@
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="],
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
@@ -2850,6 +2922,8 @@
"tachi-seeds-scripts": ["tachi-seeds-scripts@workspace:typescript/seeds-scripts"],
"tachi-seeds-webui": ["tachi-seeds-webui@workspace:typescript/seeds-webui"],
"tachi-server": ["tachi-server@workspace:typescript/server"],
"tachi-sieglinde": ["tachi-sieglinde@workspace:typescript/sieglinde"],
@@ -3004,6 +3078,8 @@
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+1
View File
@@ -9,6 +9,7 @@ VITE_FLO_CLIENT_ID=""
VITE_RULES_READ_TIME=1
VITE_IS_LOCAL_DEV=true
VITE_IS_DEVELOPMENT=true
VITE_SEEDS_URL="http://127.0.0.1:3100"
# Where routing should "base" from.
# shouldn't be used, really.
VITE_BASE_PATH=""
+1
View File
@@ -9,6 +9,7 @@ VITE_MIN_CLIENT_ID=""
VITE_FLO_CLIENT_ID=""
VITE_RULES_READ_TIME=30
VITE_IS_LOCAL_DEV=true
VITE_SEEDS_URL="https://seeds.tachi.ac"
TACHI_NAME="Tachi Local Dev"
# turn this off if you're on linux for actually good performance
@@ -1,226 +1,13 @@
import useSetSubheader from "#components/layout/header/useSetSubheader";
import SeedsPicker from "#components/seeds/SeedsPicker";
import SeedsStateViewer from "#components/seeds/SeedsStateViewer";
import Divider from "#components/util/Divider";
import ExternalLink from "#components/util/ExternalLink";
import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery";
import SelectButton from "#components/util/SelectButton";
import { TachiConfig } from "#lib/config";
import { type Revision } from "#types/git";
import { LoadCommit } from "#util/seeds";
import React, { useEffect, useState } from "react";
import { Alert, Col, Row } from "react-bootstrap";
import { useEffect } from "react";
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
export default function SeedsViewer() {
useSetSubheader(["Developer Utils", "Database Seeds Management"]);
const { data, error: failedToGetLocalAPI } = useApiQuery<Record<string, never>>("/seeds");
const { data: hasUncommittedRes } = useApiQuery<boolean>("/seeds/has-uncommitted-changes");
if (!data && !failedToGetLocalAPI) {
return <Loading />;
}
return (
<>
<h1>{TachiConfig.NAME} Database Management</h1>
<Divider />
<span>
This tool is for viewing the database that powers {TachiConfig.NAME} in a more
efficient manner.
<br />
To view the state of a given commit or repository, use the select boxes below.
<br />
For more information what all of this is about and how it works, see{" "}
<ExternalLink href="https://docs.tachi.ac/contributing/components/seeds">
the documentation
</ExternalLink>
.
{!failedToGetLocalAPI && (
<>
<br />
<br />
<b>
Also, it seems like you're running {TachiConfig.NAME} in local
development!
</b>{" "}
<br />
You can use this UI to view your current changes on-your-disk before you
commit them!
</>
)}
</span>
<Divider />
{hasUncommittedRes && (
<>
<Alert
className="d-flex w-100 justify-content-center"
style={{
flexDirection: "column",
alignItems: "center",
gap: "20px",
}}
variant="warning"
>
<div>You have uncommitted changes on your local disk.</div>
<div>
{/* this deliberately reloads the page. */}
<a
className="btn btn-secondary"
href="/utils/seeds?repo=local&sha=WORKING_DIRECTORY"
>
Click to view what you've changed.
</a>
</div>
</Alert>{" "}
<Divider />
</>
)}
<InnerSeedsViewer hasLocalAPI={!failedToGetLocalAPI} />
</>
);
}
function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
// base, rev a-la traditional git comparisons. Head is expected to be 'after'
// the base, but no order is enforced.
const [baseRev, setBaseRev] = useState<Revision | null>(null);
const [headRev, setHeadRev] = useState<Revision | null>(null);
const [urlLoading, setURLLoading] = useState(false);
const [isFirstLoad, setIsFirstLoad] = useState(true);
const params = new URLSearchParams(window.location.search);
const sha = params.get("sha");
const repo = params.get("repo");
const compareRepo = params.get("compareRepo");
const compareSHA = params.get("compareSHA");
const [view, setView] = useState<"DIFF" | "FULL">("DIFF");
useEffect(() => {
if (!isFirstLoad) {
return;
if (SEEDS_URL) {
window.location.replace(SEEDS_URL);
}
}, []);
// if we've been given stuff in the URL and don't already have a baseRev
if (sha && repo && !baseRev) {
setURLLoading(true);
(async () => {
const baseCommit = await LoadCommit(repo, sha);
if (baseCommit) {
setBaseRev({ c: baseCommit, repo });
} else {
setBaseRev(null);
}
if (compareRepo && compareSHA) {
const headCommit = await LoadCommit(compareRepo, compareSHA);
if (headCommit) {
setHeadRev({ c: headCommit, repo: compareRepo });
} else {
setHeadRev(null);
}
}
setURLLoading(false);
setIsFirstLoad(false);
})();
}
}, [isFirstLoad]);
useEffect(() => {
if (baseRev) {
const params = new URLSearchParams({
repo: baseRev.repo,
sha: baseRev.c.sha,
});
if (headRev) {
params.set("compareRepo", headRev.repo);
params.set("compareSHA", headRev.c.sha);
}
window.history.replaceState(null, "", `?${params.toString()}`);
} else {
window.history.replaceState(null, "", "");
}
}, [baseRev, headRev]);
// if sha XOR repo, this should not be allowed.
if (!!sha !== !!repo) {
return <div>Invalid reference, both a repo and a sha must be provided in the URL.</div>;
}
if (urlLoading) {
return (
<div className="w-100 d-flex flex-column justify-content-center">
<Loading />
<div className="mt-2 text-center">
Fetching information for{" "}
<code>
{repo}/{sha}
</code>
...
</div>
</div>
);
}
return (
<>
<Row>
<Col className="text-center" lg={baseRev ? 6 : 12} xs={12}>
<SeedsPicker
hasLocalAPI={hasLocalAPI}
header="Base Commit"
rev={baseRev}
setRev={setBaseRev}
/>
</Col>
{baseRev && (
<Col className="text-center" lg={6} xs={12}>
<SeedsPicker
hasLocalAPI={hasLocalAPI}
header="Compare Commit"
message="Optionally, pick another arbitary commit to show all the changes between.
This commit should be newer than the base commit!"
rev={headRev}
setRev={setHeadRev}
/>
</Col>
)}
</Row>
{baseRev && (
<Row>
<Col xs={12}>
<Divider />
{!headRev && (
<div className="w-100 d-flex justify-content-center">
<SelectButton
className="mx-2"
id="DIFF"
setValue={setView}
value={view}
>
View Changes
</SelectButton>
<SelectButton id="FULL" setValue={setView} value={view}>
View Full State
</SelectButton>
</div>
)}
<Divider />
</Col>
<SeedsStateViewer baseRev={baseRev} headRev={headRev} view={view} />
</Row>
)}
</>
);
return null;
}
@@ -2,13 +2,22 @@ import DropdownNavLink from "#components/ui/DropdownNavLink";
import QuickDropdown from "#components/ui/QuickDropdown";
import { type SetState } from "#types/react";
import React from "react";
import DropdownItem from "react-bootstrap/DropdownItem";
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) {
return (
<>
<DropdownNavLink onClick={onClick} to="/utils/seeds">
<DropdownItem
className="rounded focus-visible-ring"
href={SEEDS_URL}
onClick={onClick}
rel="noopener noreferrer"
target="_blank"
>
Seeds Management
</DropdownNavLink>
</DropdownItem>
<DropdownNavLink onClick={onClick} to="/utils/imports">
Import Management
</DropdownNavLink>
+12 -3
View File
@@ -144,14 +144,23 @@ const v3GameMappings: Record<LEGACY_GPTString, V3Game> = {
"ddr:DP": "ddr-dp",
};
/**
* @deprecated
*/
export function LEGACY_GameGroupPTToGame(game: GameGroup, playtype: LEGACY_Playtype): V3Game {
return GPTStringToGame(LEGACY_GetGPTString(game, playtype));
return LEGACY_GPTStringToGame(LEGACY_GetGPTString(game, playtype));
}
export function GPTStringToGame(gptString: LEGACY_GPTString): V3Game {
/**
* @deprecated
*/
export function LEGACY_GPTStringToGame(gptString: LEGACY_GPTString): V3Game {
return v3GameMappings[gptString];
}
/**
* @deprecated
*/
export function LEGACY_GameToGPTString(game: V3Game): LEGACY_GPTString {
const mapping: Record<V3Game, LEGACY_GPTString> = {
"iidx-sp": "iidx:SP",
@@ -241,7 +250,7 @@ export function LEGACY_GetGamePTConfig(
}
export function LEGACY_GetGPTConfig(gptString: LEGACY_GPTString): GameConfig {
const game = GPTStringToGame(gptString);
const game = LEGACY_GPTStringToGame(gptString);
return GAME_CONFIGS[game] as unknown as GameConfig;
}
+24 -12
View File
@@ -357,23 +357,35 @@ function encodeBmsTableKey(game: string, key: string): string {
function encodeBmsTableValue(value: string): string {
const parts: Array<string> = [];
let alphanumRun = "";
for (const ch of value) {
if (/[a-z0-9]/iu.test(ch)) {
parts.push(ch.toLowerCase());
} else if (ch === "+") {
parts.push("p");
} else if (ch === "?") {
parts.push("q");
} else if (ch === "-") {
parts.push("dash");
} else if (ch === "!") {
parts.push("excl");
alphanumRun += ch.toLowerCase();
} else {
parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`);
if (alphanumRun.length > 0) {
parts.push(alphanumRun);
alphanumRun = "";
}
if (ch === "+") {
parts.push("p");
} else if (ch === "?") {
parts.push("q");
} else if (ch === "-") {
parts.push("dash");
} else if (ch === "!") {
parts.push("excl");
} else {
parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`);
}
}
}
if (alphanumRun.length > 0) {
parts.push(alphanumRun);
}
return parts.join("-");
}
@@ -386,7 +398,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string {
const key = mKv[1].replaceAll("''", "'");
const val = mKv[2].replaceAll("''", "'");
return `tf-${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`;
return `${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`;
}
const mPr = /tableFolders'\) \? '((?:[^']|'')+)'/u.exec(w);
@@ -394,7 +406,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string {
if (mPr !== null) {
const key = mPr[1].replaceAll("''", "'");
return `tf-${encodeBmsTableKey(folder.game, key)}-present`;
return `${encodeBmsTableKey(folder.game, key)}-present`;
}
const mRg =
@@ -8,8 +8,8 @@ import fs from "fs";
import path from "path";
import {
type GameGroup,
GPTStringToGame,
LEGACY_GetGPTString,
LEGACY_GPTStringToGame,
type LEGACY_Playtype,
} from "tachi-common";
@@ -58,7 +58,7 @@ for (const entry of folders) {
delete entry.folderID;
}
entry.game = GPTStringToGame(
entry.game = LEGACY_GPTStringToGame(
LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype),
);
delete entry.playtype;
@@ -82,7 +82,7 @@ for (const entry of tables) {
delete entry.tableID;
}
entry.game = GPTStringToGame(
entry.game = LEGACY_GPTStringToGame(
LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype),
);
delete entry.playtype;
+12
View File
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
function ChartSort(a, b) {
// sink all 2dxtra charts to the bottom
@@ -98,6 +99,17 @@ function SortSeeds() {
fs.writeFileSync(collPath, JSON.stringify(content, null, "\t"));
}
// Run the repo formatter so the output is consistent with `just fmt`.
const repoRoot = path.resolve(__dirname, "../..");
const biome = path.join(repoRoot, "node_modules", ".bin", "biome");
const result = spawnSync(biome, ["format", "--write", collectionsDir], {
cwd: repoRoot,
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`biome format exited ${result.status}`);
}
}
function SortObjectKeys(object) {
+4
View File
@@ -0,0 +1,4 @@
build/
dist/
public/seeds-bundle/
node_modules/
+40
View File
@@ -0,0 +1,40 @@
# tachi-seeds-webui
The seeds workstation. Browse, query, diff over git history — and, in
localdev, edit `db/seeds/*.json` directly.
## Running
```sh
# Local dev (editing enabled, spawns a Vite dev server on :3100)
just seeds-webui
# Build a static, read-only bundle (no editing)
cd typescript/seeds-webui && bun run build
```
## Shape
- **Static SPA in prod.** Hosted at `seeds.tachi.ac`. No backend; reads
commit history from the GitHub REST API and the current seeds from a
pre-built `/seeds-bundle/` snapshot shipped with the site.
- **Dev mode adds a Vite plugin** (`dev/vite-plugin-seeds-dev.ts`) that
mounts `/__seeds/*` against the local repo's `db/seeds/`. That plugin is
_only_ loaded during `vite dev`; `vite build` never includes it.
- **In-browser SQLite** (`@sqlite.org/sqlite-wasm` + OPFS) for queries.
The worker at `src/lib/sqlite/worker.ts` ingests collections on first
load and caches them in OPFS by content hash.
- **Drafts** are queued in IndexedDB. The Drafts drawer composes them into
JSON Patches and calls `transport.writeCollection`, which in dev mode
rewrites the file and re-runs `sort-seeds.js`.
## Edit-mode safety
Two locks:
1. `VITE_SEEDS_EDIT_MODE` is set to the literal `false` during `vite
build`, so every `EDIT_MODE`-guarded branch is tree-shaken out of the
prod bundle.
2. At runtime, the transport additionally probes `/__seeds/ping` before
unlocking edit paths. A static host where somebody deploys a dev build
by mistake will still act read-only.
+124
View File
@@ -0,0 +1,124 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const pExecFile = promisify(execFile);
export interface DevBranch {
name: string;
sha: string;
}
export interface DevCommit {
sha: string;
message: string;
author: { date: string; email: string; name: string };
committer: { date: string; email: string; name: string };
parents: Array<{ sha: string }>;
}
export async function gitExec(
repoRoot: string,
args: string[],
): Promise<{ stderr: string; stdout: string }> {
return pExecFile("git", args, {
cwd: repoRoot,
env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" },
maxBuffer: 256 * 1024 * 1024,
});
}
export async function listBranches(
repoRoot: string,
): Promise<{ branches: DevBranch[]; current: DevBranch | null }> {
const { stdout } = await gitExec(repoRoot, [
"for-each-ref",
"--format=%(refname:short)\t%(objectname:short)",
"refs/heads",
]);
const branches: DevBranch[] = [];
for (const line of stdout.split("\n")) {
const [name, sha] = line.split("\t");
if (name && sha) {branches.push({ name, sha });}
}
let current: DevBranch | null = null;
try {
const { stdout: head } = await gitExec(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
const name = head.trim();
if (name && name !== "HEAD") {
const found = branches.find((b) => b.name === name);
if (found) {current = found;}
}
} catch {
// detached head — leave current null.
}
return { branches, current };
}
// git log format: each commit is 7 records terminated by NUL, separated
// from the next commit by a literal "\x1e" (record separator).
const LOG_FORMAT = ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%s"].join("%x00");
export async function listCommits(
repoRoot: string,
opts: { branch?: string; file?: string; limit?: number; skip?: number },
): Promise<DevCommit[]> {
const args = [
"log",
`--format=${LOG_FORMAT}%x1e`,
`--max-count=${opts.limit ?? 30}`,
`--skip=${opts.skip ?? 0}`,
];
if (opts.branch) {args.push(opts.branch);}
if (opts.file) {args.push("--", opts.file);}
const { stdout } = await gitExec(repoRoot, args);
const commits: DevCommit[] = [];
for (const record of stdout.split("\x1e")) {
const parts = record.trim().split("\x00");
if (parts.length < 9) {continue;}
const [sha, parents, an, ae, ad, cn, ce, cd, message] = parts;
commits.push({
sha: sha!,
parents: (parents ?? "")
.split(" ")
.filter(Boolean)
.map((p) => ({ sha: p })),
author: { name: an!, email: ae!, date: ad! },
committer: { name: cn!, email: ce!, date: cd! },
message: message!,
});
}
return commits;
}
export async function getCommit(repoRoot: string, sha: string): Promise<DevCommit> {
const [c] = await listCommits(repoRoot, { branch: sha, limit: 1 });
if (!c) {throw new Error(`No commit found for ${sha}`);}
return c;
}
export async function showFileAt(repoRoot: string, rev: string, path: string): Promise<string> {
const { stdout } = await gitExec(repoRoot, ["show", `${rev}:${path}`]);
return stdout;
}
export async function statusPorcelain(
repoRoot: string,
): Promise<{ branch: string | null; changed: string[]; hasUncommitted: boolean }> {
const { stdout } = await gitExec(repoRoot, ["status", "--porcelain=v1", "--branch"]);
const lines = stdout.split("\n").filter(Boolean);
let branch: string | null = null;
const changed: string[] = [];
for (const line of lines) {
if (line.startsWith("##")) {
const m = /^## ([^.\s]+)(?:\.\.\.|$| )/u.exec(line);
branch = m?.[1] ?? null;
continue;
}
// Porcelain format: " XY path" — strip the two-char status + space.
changed.push(line.slice(3));
}
return { branch, changed, hasUncommitted: changed.length > 0 };
}
@@ -0,0 +1,111 @@
// Minimal JSON Patch (RFC 6902) applier limited to the three ops the UI emits:
// add, remove, replace. Paths use '/' separators; '-' as last segment on an
// array means "append".
//
// We deliberately do NOT depend on fast-json-patch here because this runs in
// the dev plugin (Node side) and we want zero Node-side dependencies beyond
// Vite itself.
export type JsonPatchOp =
| { op: "add"; path: string; value: unknown }
| { op: "remove"; path: string }
| { op: "replace"; path: string; value: unknown };
export type JsonPatch = JsonPatchOp[];
type Json = unknown;
function decode(segment: string): string {
return segment.replace(/~1/gu, "/").replace(/~0/gu, "~");
}
function split(path: string): string[] {
if (path === "") {return [];}
if (path[0] !== "/") {
throw new Error(`Invalid JSON Pointer: ${path}`);
}
return path.slice(1).split("/").map(decode);
}
function isObj(v: unknown): v is Record<string, Json> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function walk(
root: Json,
segments: string[],
): { exists: boolean; key: number | string; parent: Json } {
if (segments.length === 0) {
throw new Error(
"Cannot address the root via a patch op here; edit the whole file instead.",
);
}
let cur: Json = root;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i]!;
if (Array.isArray(cur)) {
const idx = Number(seg);
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) {
throw new Error(
`Array index out of bounds at ${segments.slice(0, i + 1).join("/")}`,
);
}
cur = cur[idx];
} else if (isObj(cur)) {
if (!(seg in cur)) {
throw new Error(`Missing key "${seg}" at ${segments.slice(0, i + 1).join("/")}`);
}
cur = cur[seg];
} else {
throw new Error(
`Cannot descend into non-container at ${segments.slice(0, i + 1).join("/")}`,
);
}
}
const last = segments[segments.length - 1]!;
if (Array.isArray(cur)) {
const key = last === "-" ? cur.length : Number(last);
if (last !== "-" && (!Number.isInteger(key) || key < 0 || key > cur.length)) {
throw new Error(`Array index out of bounds at ${segments.join("/")}`);
}
return { parent: cur, key, exists: last !== "-" && key < cur.length };
}
if (isObj(cur)) {
return { parent: cur, key: last, exists: last in cur };
}
throw new Error(`Cannot address ${segments.join("/")}: parent is not a container`);
}
export function applyPatch<T>(doc: T, patch: JsonPatch): T {
const clone = JSON.parse(JSON.stringify(doc)) as T;
for (const op of patch) {
const segs = split(op.path);
const { parent, key } = walk(clone, segs);
if (Array.isArray(parent)) {
const idx = key as number;
switch (op.op) {
case "add":
parent.splice(idx, 0, op.value);
break;
case "remove":
parent.splice(idx, 1);
break;
case "replace":
parent[idx] = op.value;
break;
}
} else if (isObj(parent)) {
const k = key as string;
switch (op.op) {
case "add":
case "replace":
parent[k] = op.value;
break;
case "remove":
delete parent[k];
break;
}
}
}
return clone;
}
@@ -0,0 +1,293 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin, ViteDevServer } from "vite";
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import {
getCommit,
gitExec,
listBranches,
listCommits,
showFileAt,
statusPorcelain,
} from "./util/git";
import { applyPatch, type JsonPatch } from "./util/json-patch";
// vite-plugin-seeds-dev — mounts /__seeds/* dev-only endpoints against the
// local repo's db/seeds directory so the seeds-webui can:
//
// - GET /__seeds/ping feature probe
// - GET /__seeds/collections list *.json in db/seeds
// - GET /__seeds/branches local git branches
// - GET /__seeds/commits log entries affecting db/seeds (optionally ?file=)
// - GET /__seeds/commit?sha=... details of a specific commit
// - GET /__seeds/collection?name=&rev= full JSON of a collection at a rev (or disk)
// - POST /__seeds/collection apply JSON patch, re-sort, write
// - POST /__seeds/sort run sort-seeds.js
// - GET /__seeds/status working-tree status
// - POST /__seeds/tests/run run seeds-scripts tests, stream (SSE)
//
// All endpoints are ONLY registered during `vite dev`. They never make it into
// the production bundle.
export interface SeedsDevPluginOptions {
repoRoot: string;
}
export function seedsDevPlugin(opts: SeedsDevPluginOptions): Plugin {
const seedsDir = path.join(opts.repoRoot, "db", "seeds");
const seedsRepoRelPath = "db/seeds";
const seedsScriptsDir = path.join(opts.repoRoot, "typescript", "seeds-scripts");
return {
name: "seeds-webui:dev",
apply: "serve",
configureServer(server: ViteDevServer) {
server.middlewares.use("/__seeds", async (req, res) => {
try {
await handle(req, res, {
repoRoot: opts.repoRoot,
seedsDir,
seedsRepoRelPath,
seedsScriptsDir,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
server.config.logger.error(`[seeds-webui] ${req.url ?? ""}: ${message}`);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: message }));
}
}
});
},
};
}
interface Ctx {
repoRoot: string;
seedsDir: string;
seedsRepoRelPath: string;
seedsScriptsDir: string;
}
// ---------- Dispatcher ----------
async function handle(req: IncomingMessage, res: ServerResponse, ctx: Ctx) {
const url = new URL(req.url ?? "/", "http://localhost");
const method = (req.method ?? "GET").toUpperCase();
// `req.url` on middleware is already stripped of the /__seeds prefix.
const route = `${method} ${url.pathname.replace(/\/+$/u, "")}`;
switch (route) {
case "GET /ping":
case "GET /":
return respond(res, 200, { ok: true });
case "GET /collections":
return respond(res, 200, await listCollectionFiles(ctx));
case "GET /branches":
return respond(res, 200, await listBranches(ctx.repoRoot));
case "GET /commits":
return respond(
res,
200,
await commitsHandler(ctx, {
branch: url.searchParams.get("branch") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
file: url.searchParams.get("file") ?? undefined,
}),
);
case "GET /commit": {
const sha = url.searchParams.get("sha");
if (!sha) {return respond(res, 400, { error: "missing sha" });}
return respond(res, 200, await getCommit(ctx.repoRoot, sha));
}
case "GET /collection": {
const name = url.searchParams.get("name");
const rev = url.searchParams.get("rev") ?? undefined;
if (!name) {return respond(res, 400, { error: "missing name" });}
return respond(res, 200, await readCollection(ctx, name, rev));
}
case "POST /collection": {
const body = await readJson<{ name: string; patch: JsonPatch }>(req);
await writeCollection(ctx, body.name, body.patch);
return respond(res, 200, { ok: true });
}
case "POST /sort": {
await runSort(ctx);
return respond(res, 200, { ok: true });
}
case "GET /status":
return respond(res, 200, await statusHandler(ctx));
case "POST /tests/run":
return streamProcess(res, ctx.seedsScriptsDir, "bash", ["./run-tests.sh"]);
}
return respond(res, 404, { error: `no seeds-webui dev route for ${route}` });
}
// ---------- Helpers ----------
function respond(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(body));
}
async function readJson<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const c of req) {
chunks.push(c as Buffer);
}
const raw = Buffer.concat(chunks).toString("utf-8");
if (raw.length === 0) {return {} as T;}
return JSON.parse(raw) as T;
}
// ---------- Endpoint handlers ----------
async function listCollectionFiles(ctx: Ctx): Promise<string[]> {
const names = await fs.readdir(ctx.seedsDir);
return names.filter((n) => n.endsWith(".json")).sort();
}
async function commitsHandler(
ctx: Ctx,
opts: { branch?: string; cursor?: string; file?: string },
): Promise<{
commits: Awaited<ReturnType<typeof listCommits>>;
nextCursor?: string;
}> {
const skip = opts.cursor ? Number.parseInt(opts.cursor, 10) : 0;
const limit = 30;
const targetFile = opts.file ? `${ctx.seedsRepoRelPath}/${opts.file}` : ctx.seedsRepoRelPath;
const commits = await listCommits(ctx.repoRoot, {
branch: opts.branch,
file: targetFile,
limit,
skip,
});
return {
commits,
nextCursor: commits.length === limit ? String(skip + limit) : undefined,
};
}
async function readCollection(ctx: Ctx, name: string, rev?: string): Promise<unknown[]> {
validateCollectionName(name);
if (rev === undefined) {
const raw = await fs.readFile(path.join(ctx.seedsDir, name), "utf-8");
return JSON.parse(raw) as unknown[];
}
validateRev(rev);
const raw = await showFileAt(ctx.repoRoot, rev, `${ctx.seedsRepoRelPath}/${name}`);
return JSON.parse(raw) as unknown[];
}
async function writeCollection(ctx: Ctx, name: string, patch: JsonPatch): Promise<void> {
validateCollectionName(name);
const file = path.join(ctx.seedsDir, name);
const raw = await fs.readFile(file, "utf-8");
const doc = JSON.parse(raw) as unknown[];
const next = applyPatch(doc, patch);
// Write atomically (same dir so it stays on the same fs).
const tmp = `${file}.tmp-${process.pid}`;
await fs.writeFile(tmp, JSON.stringify(next, null, "\t"));
await fs.rename(tmp, file);
// Re-run sort-seeds so diffs stay canonical.
await runSort(ctx);
}
async function runSort(ctx: Ctx): Promise<void> {
await new Promise<void>((resolve, reject) => {
const proc = spawn("node", ["sort-seeds.js"], {
cwd: ctx.seedsScriptsDir,
stdio: "inherit",
});
proc.on("error", reject);
proc.on("exit", (code) =>
code === 0 ? resolve() : reject(new Error(`sort-seeds exited ${code}`)),
);
});
}
async function statusHandler(ctx: Ctx) {
const s = await statusPorcelain(ctx.repoRoot);
return {
branch: s.branch,
hasUncommittedChanges: s.changed.some((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)),
changedFiles: s.changed.filter((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)),
};
}
function streamProcess(
res: ServerResponse,
cwd: string,
cmd: string,
argv: string[],
): Promise<void> {
return new Promise((resolve) => {
res.statusCode = 200;
res.setHeader("content-type", "text/event-stream");
res.setHeader("cache-control", "no-cache");
res.setHeader("x-accel-buffering", "no");
const write = (kind: "stderr" | "stdout", data: string) => {
res.write(`data: ${JSON.stringify({ kind, data })}\n\n`);
};
const proc = spawn(cmd, argv, { cwd });
proc.stdout.on("data", (b: Buffer) => write("stdout", b.toString("utf-8")));
proc.stderr.on("data", (b: Buffer) => write("stderr", b.toString("utf-8")));
proc.on("error", (err) => write("stderr", `spawn error: ${err.message}\n`));
proc.on("exit", (code) => {
res.write(`data: ${JSON.stringify({ kind: "exit", code: code ?? -1 })}\n\n`);
res.end();
resolve();
});
});
}
// ---------- Validation ----------
function validateCollectionName(name: string): void {
if (!/^[a-z0-9][a-z0-9-]*\.json$/u.test(name)) {
throw new Error(`invalid collection name ${JSON.stringify(name)}`);
}
}
// Allow branches, tags, shas, and short shas. Disallow shell metacharacters.
function validateRev(rev: string): void {
if (!/^[A-Za-z0-9._/-]+$/u.test(rev)) {
throw new Error(`invalid git revision ${JSON.stringify(rev)}`);
}
}
// Re-export for the Vite config typecheck.
export type { JsonPatch };
// Used only in tests/dev to warm up git; exported so that callers can verify
// the repo is actually a git repo before mounting.
export async function gitSanityCheck(repoRoot: string): Promise<boolean> {
try {
await gitExec(repoRoot, ["rev-parse", "--git-dir"]);
return true;
} catch {
return false;
}
}
+19
View File
@@ -0,0 +1,19 @@
import configTachi from "eslint-config-tachi";
export default [
...configTachi.base,
{
...configTachi.node,
files: ["vite.config.ts", "dev/**/*.ts", "scripts/**/*.ts"],
languageOptions: {
...configTachi.node.languageOptions,
parserOptions: {
project: ["./tsconfig.node.json"],
},
},
},
configTachi.react,
{
ignores: ["build/**", "public/**", "node_modules/**", "dist/**"],
},
];
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Tachi Seeds</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Browse, query, diff, and edit Tachi's seeds database.">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
// Bootstrap the data-bs-theme attribute before any CSS applies so we don't
// flash a light background on dark-mode users.
const root = document.documentElement;
const theme = localStorage.getItem("seeds-webui-theme")
|| (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
root.style.setProperty("color-scheme", theme === "light" ? "light" : "dark");
root.setAttribute("data-bs-theme", theme);
</script>
<script type="module" src="/src/main.tsx"></script>
</head>
<body>
<noscript>
This site is a single-page React app and requires JavaScript.
</noscript>
<div id="root"></div>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
{
"name": "tachi-seeds-webui",
"version": "0.1.0-see-project-root",
"private": true,
"type": "module",
"license": "AGPL3",
"description": "Workstation UI for Tachi's db/seeds: browse, query, diff over git history, and (in localdev) edit.",
"scripts": {
"dev": "vite",
"build": "bun scripts/bundle-current-seeds.ts && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@lezer/highlight": "^1.2.3",
"@sqlite.org/sqlite-wasm": "^3.51.2-build9",
"codemirror": "^6.0.2",
"comlink": "^4.4.2",
"fast-json-patch": "^3.1.1",
"nanoid": "catalog:",
"natural-compare": "catalog:",
"react": "catalog:",
"react-bootstrap": "catalog:",
"react-dom": "catalog:",
"react-query": "catalog:",
"react-router-dom": "catalog:",
"tachi-common": "workspace:*",
"zod": "catalog:"
},
"devDependencies": {
"@types/natural-compare": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-router-dom": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "catalog:",
"bootstrap-scss": "catalog:",
"eslint-config-tachi": "workspace:*",
"sass": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-html": "catalog:"
},
"engines": {
"bun": ">=1.2.0"
},
"imports": {
"#*": [
"./src/*",
"./src/*.ts",
"./src/*.tsx",
"./src/*/index.ts",
"./src/*/index.tsx"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 400 400" style="enable-background:new 0 0 400 400;" xml:space="preserve">
<style type="text/css">
.st0{fill:#DCDFDC;}
.st1{fill:#E61C6E;}
</style>
<polyline class="st0" points="366.49,162.16 334,214.58 177.88,214.58 194.02,232.36 149.64,303.98 79.03,303.98 166.92,162.16
366.49,162.16 " />
<polyline class="st1" points="320.1,96.09 120.52,96.09 32.63,237.91 103.24,237.91 158.64,148.51 287.61,148.51 320.1,96.09 " />
</svg>

After

Width:  |  Height:  |  Size: 608 B

@@ -0,0 +1,63 @@
// Pre-build step: copy db/seeds/*.json into public/seeds-bundle/ so the prod
// SPA can render the "current state" without touching the GitHub API.
//
// Produces:
// public/seeds-bundle/<name>.json (1:1 copy of each collection)
// public/seeds-bundle/manifest.json { sha, files: [{name, bytes, hash}] }
//
// Run with `bun scripts/bundle-current-seeds.ts`.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../../..");
const SRC = path.join(REPO_ROOT, "db", "seeds");
const OUT = path.join(__dirname, "..", "public", "seeds-bundle");
async function main() {
await fs.mkdir(OUT, { recursive: true });
const files = (await fs.readdir(SRC)).filter((n) => n.endsWith(".json")).sort();
const manifest: {
files: Array<{ bytes: number; hash: string; name: string }>;
sha: string | null;
} = {
sha: detectSha(),
files: [],
};
for (const name of files) {
const srcPath = path.join(SRC, name);
const buf = await fs.readFile(srcPath);
const hash = createHash("sha256").update(buf).digest("hex").slice(0, 16);
await fs.writeFile(path.join(OUT, name), buf);
manifest.files.push({ bytes: buf.byteLength, hash, name });
}
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
const totalBytes = manifest.files.reduce((s, f) => s + f.bytes, 0);
console.log(
`[seeds-webui] bundled ${files.length} files (${(totalBytes / 1024 / 1024).toFixed(1)} MiB) -> ${path.relative(REPO_ROOT, OUT)}`,
);
}
function detectSha(): string | null {
try {
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT })
.toString("utf-8")
.trim();
} catch {
return null;
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
import { AppShell } from "#components/AppShell";
import { IngestToast } from "#components/IngestToast";
import { EDIT_MODE } from "#lib/config";
import { IngestProvider } from "#lib/ingest/IngestProvider";
import { Collection } from "#pages/Collection";
import { Diff } from "#pages/Diff";
import { Overview } from "#pages/Overview";
import { Query } from "#pages/Query";
import React, { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 60_000,
},
},
});
// Load the editing routes lazily via a single dynamic import. The import is
// only reached when EDIT_MODE is true; since EDIT_MODE is replaced with the
// literal `false` during `vite build`, rollup drops the entire expression —
// which means `editRoutes.ts` and every page it references is never emitted
// into the prod bundle.
type EditRoute = { component: React.ComponentType; path: string };
function useEditRoutes(): EditRoute[] {
const [routes, setRoutes] = useState<EditRoute[]>([]);
useEffect(() => {
if (!EDIT_MODE) {return;}
let alive = true;
import("./app/editRoutes")
.then((m) => {
if (alive) {setRoutes(m.editRoutes);}
})
.catch((err) => {
console.error("[seeds-webui] failed to load edit routes:", err);
});
return () => {
alive = false;
};
}, []);
return routes;
}
export default function App() {
const editRoutes = useEditRoutes();
return (
<QueryClientProvider client={queryClient}>
<IngestProvider>
<BrowserRouter>
<AppShell>
<Switch>
<Route component={Overview} exact path="/" />
<Route component={Query} exact path="/query" />
<Route
exact
path="/history"
render={() => <Redirect to="/diff" />}
/>
<Route component={Diff} exact path="/diff" />
<Route component={Collection} exact path="/c/:name" />
{editRoutes.map((r) => (
<Route component={r.component} exact key={r.path} path={r.path} />
))}
</Switch>
</AppShell>
<IngestToast />
</BrowserRouter>
</IngestProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,15 @@
import type React from "react";
import { Bulk } from "#pages/Bulk";
import { Drafts } from "#pages/Drafts";
import { Validate } from "#pages/Validate";
// This module is only imported from a `EDIT_MODE ? import("...")` call site,
// which in a prod build (with EDIT_MODE === false literal) is dead code. As a
// result rollup never traces through this file and none of the edit-only
// pages end up in the output bundle.
export const editRoutes: Array<{ component: React.ComponentType; path: string }> = [
{ component: Bulk, path: "/bulk" },
{ component: Drafts, path: "/drafts" },
{ component: Validate, path: "/validate" },
];
@@ -0,0 +1,136 @@
import { DevStrip } from "#components/DevStrip";
import { EDIT_MODE } from "#lib/config";
import { listDrafts } from "#lib/edits/draft-store";
import React, { useEffect, useState } from "react";
import { useQuery } from "react-query";
import { Link, NavLink, useLocation } from "react-router-dom";
function useDraftCount(): number {
const { data = 0 } = useQuery(
"draft-count",
async () => {
if (!EDIT_MODE) {return 0;}
return (await listDrafts()).length;
},
{
refetchInterval: EDIT_MODE ? 2000 : false,
staleTime: 0,
},
);
return data;
}
export function AppShell({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useTheme();
const draftCount = useDraftCount();
const location = useLocation();
const onDraftsPage = location.pathname === "/drafts";
return (
<div className="app-shell">
<header className="sw-header">
<NavLink activeClassName="" className="brand" exact to="/">
<img alt="Tachi" src="/logos/logo-mark.png" />
<div className="brand-wordmark">
Tachi
<small>Seeds</small>
</div>
</NavLink>
<nav className="sw-nav">
<NavLink activeClassName="active" exact to="/">
Overview
</NavLink>
<NavLink activeClassName="active" to="/query">
Query
</NavLink>
<NavLink activeClassName="active" to="/diff">
Diff
</NavLink>
{EDIT_MODE ? (
<>
<span className="nav-sep" />
<span className="dev-label">Edit</span>
<NavLink activeClassName="active" to="/bulk">
Bulk
</NavLink>
<NavLink activeClassName="active" to="/drafts">
Drafts
{draftCount > 0 ? (
<span className="draft-badge">{draftCount}</span>
) : null}
</NavLink>
<NavLink activeClassName="active" to="/validate">
Validate
</NavLink>
</>
) : null}
</nav>
<div className="sw-header-right">
<span
className={`mode-badge ${EDIT_MODE ? "mode-dev" : "mode-readonly"}`}
title={
EDIT_MODE
? "Running against a local dev server; edits write to disk."
: "Read-only. Hosted build without a dev server."
}
>
{EDIT_MODE ? "Dev" : "Read-only"}
</span>
<button
aria-label="Toggle theme"
className="theme-toggle"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
title="Toggle theme"
type="button"
>
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
</div>
</header>
<DevStrip />
<div className="sw-body">
<main className="sw-content">{children}</main>
</div>
{EDIT_MODE && draftCount > 0 && !onDraftsPage ? (
<DraftsFloatingBar count={draftCount} />
) : null}
</div>
);
}
function DraftsFloatingBar({ count }: { count: number }) {
return (
<div className="drafts-bar">
<span className="drafts-bar-icon">✎</span>
<span className="drafts-bar-text">
<strong>{count}</strong> staged {count === 1 ? "edit" : "edits"}
</span>
<Link className="drafts-bar-link" to="/drafts">
Review &amp; Apply →
</Link>
</div>
);
}
type Theme = "dark" | "light";
function useTheme(): [Theme, (t: Theme) => void] {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof document === "undefined") {return "dark";}
return (document.documentElement.getAttribute("data-bs-theme") as Theme) ?? "dark";
});
useEffect(() => {
document.documentElement.setAttribute("data-bs-theme", theme);
document.documentElement.style.setProperty(
"color-scheme",
theme === "light" ? "light" : "dark",
);
}, [theme]);
const setTheme = (t: Theme) => {
localStorage.setItem("seeds-webui-theme", t);
setThemeState(t);
};
return [theme, setTheme];
}
@@ -0,0 +1,149 @@
import type { Row } from "#lib/diff/row-primary-key";
import {
type DiffRow,
type FieldDelta,
formatValue,
singleDocumentDiff,
} from "#lib/diff/collection-diff";
import { useSongByIdLookup } from "#lib/format/use-song-by-id-lookup";
import { useMemo, useState } from "react";
export function SingleRowDocumentDiff({
before,
after,
collectionName,
songById: songByIdProp,
}: {
after: Record<string, unknown> | null;
before: Record<string, unknown> | null;
/** When set, used for pretty titles (e.g. FormatChart). If omitted but `songById` is not passed, songs are loaded for `charts-*`. */
collectionName?: string;
songById?: Map<string, Row> | null;
}) {
const shouldFetchSongs = Boolean(collectionName && songByIdProp === undefined);
const songQuery = useSongByIdLookup(
collectionName && shouldFetchSongs ? collectionName : undefined,
);
const songById =
songByIdProp !== undefined ? songByIdProp : (songQuery.data ?? undefined);
const row = useMemo(
() => singleDocumentDiff(before, after, { collectionName, songById }),
[before, after, collectionName, songById],
);
if (before === null && after === null) {
return <div className="text-muted">No diff data.</div>;
}
if (!row) {
return <div className="text-muted">No changes.</div>;
}
return <DiffRowCard row={row} />;
}
export function DiffRows({ rows, limit = 200 }: { limit?: number; rows: DiffRow[] }) {
const visible = rows.slice(0, limit);
const truncated = rows.length - visible.length;
return (
<div className="diff-list">
{visible.map((r) => (
<DiffRowCard key={r.id} row={r} />
))}
{truncated > 0 ? (
<p className="text-muted">
… {truncated.toLocaleString()} more rows hidden. Use the filter to narrow down.
</p>
) : null}
</div>
);
}
export function DiffRowCard({ row }: { row: DiffRow }) {
const [expanded, setExpanded] = useState(false);
return (
<div className={`diff-card diff-card-${row.kind}`}>
<div className="diff-card-head">
<span className={`diff-kind diff-kind-${row.kind}`}>
{row.kind === "added" ? "+" : row.kind === "removed" ? "−" : "~"}
</span>
<code className="diff-id">{row.id}</code>
{row.pretty !== row.id ? (
<span className="diff-pretty">{row.pretty}</span>
) : null}
<span className="diff-card-spacer" />
{row.kind === "changed" ? (
<span className="diff-field-count">
{row.fields.length} field
{row.fields.length === 1 ? "" : "s"} changed
</span>
) : null}
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => setExpanded((x) => !x)}
type="button"
>
{expanded ? "Hide JSON" : "Show JSON"}
</button>
</div>
{row.kind === "changed" ? <FieldTable fields={row.fields} /> : null}
{row.kind === "added" ? (
<pre className="diff-json diff-json-added">{formatValue(row.after)}</pre>
) : null}
{row.kind === "removed" ? (
<pre className="diff-json diff-json-removed">{formatValue(row.before)}</pre>
) : null}
{expanded && row.kind === "changed" ? (
<div className="diff-json-pair">
<div>
<div className="diff-json-label">before</div>
<pre className="diff-json diff-json-removed">{formatValue(row.before)}</pre>
</div>
<div>
<div className="diff-json-label">after</div>
<pre className="diff-json diff-json-added">{formatValue(row.after)}</pre>
</div>
</div>
) : null}
</div>
);
}
function FieldTable({ fields }: { fields: FieldDelta[] }) {
return (
<table className="diff-fields">
<thead>
<tr>
<th>Field</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
{fields.map((f) => (
<tr className={`diff-fields-row diff-fields-row-${f.kind}`} key={f.path}>
<td className="diff-fields-path mono">{f.path}</td>
<td className="diff-fields-before">
{f.kind === "added" ? (
<span className="text-muted">—</span>
) : (
<pre className="mono">{formatValue(f.before)}</pre>
)}
</td>
<td className="diff-fields-after">
{f.kind === "removed" ? (
<span className="text-muted">—</span>
) : (
<pre className="mono">{formatValue(f.after)}</pre>
)}
</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,100 @@
import type { Commit } from "#lib/transport/transport";
import { useMemo } from "react";
interface CommitSparklineProps {
commits: Commit[];
/** Number of trailing days to bucket. Default 90. */
days?: number;
}
// Tiny inline SVG sparkline of commits per day, no chart lib. Good enough for
// the overview "pulse" view. Bars scale to the busiest day in the range.
export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) {
const { bins, maxCount, start, end, total } = useMemo(() => {
const now = Date.now();
const bucket = new Map<string, number>();
const startDate = new Date(now - days * 86_400_000);
startDate.setHours(0, 0, 0, 0);
// Seed every day in range with 0 so gaps show as empty bars.
for (let i = 0; i < days; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
bucket.set(d.toISOString().slice(0, 10), 0);
}
let total = 0;
for (const c of commits) {
const d = new Date(c.author.date);
if (Number.isNaN(d.getTime())) {continue;}
if (d.getTime() < startDate.getTime()) {continue;}
const key = d.toISOString().slice(0, 10);
if (!bucket.has(key)) {continue;}
bucket.set(key, (bucket.get(key) ?? 0) + 1);
total++;
}
const bins = Array.from(bucket.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([day, count]) => ({ count, day }));
const maxCount = bins.reduce((m, b) => Math.max(m, b.count), 0);
return {
bins,
end: new Date(),
maxCount,
start: startDate,
total,
};
}, [commits, days]);
const width = 600;
const height = 70;
const pad = 2;
const barW = (width - pad * (bins.length + 1)) / bins.length;
return (
<div className="sparkline-wrap">
<div className="sparkline-head">
<span className="sparkline-title">Commit activity</span>
<span className="sparkline-meta">
{total} commit{total === 1 ? "" : "s"} in the last {days} days
</span>
</div>
<svg
aria-label={`Commit activity over the last ${days} days`}
className="sparkline"
preserveAspectRatio="none"
role="img"
viewBox={`0 0 ${width} ${height}`}
>
{bins.map((b, i) => {
const h = maxCount === 0 ? 0 : (b.count / maxCount) * (height - 4);
const x = pad + i * (barW + pad);
const y = height - h - 2;
const active = b.count > 0;
return (
<rect
className={active ? "sparkline-bar is-active" : "sparkline-bar"}
height={Math.max(h, active ? 2 : 1)}
key={b.day}
rx={1}
width={barW}
x={x}
y={y}
>
<title>
{b.day} — {b.count} commit{b.count === 1 ? "" : "s"}
</title>
</rect>
);
})}
</svg>
<div className="sparkline-scale">
<span>{start.toLocaleDateString()}</span>
<span>{end.toLocaleDateString()}</span>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { getTransport } from "#lib/transport/transport";
import { useQuery } from "react-query";
// Thin horizontal strip under the header in dev mode that shows the current
// branch + working-tree dirtiness. Rendered only when the transport is the
// dev one (i.e. /__seeds/ping succeeded).
export function DevStrip() {
const { data: mode } = useQuery("transport-mode", async () => (await getTransport()).mode, {
staleTime: Infinity,
});
const { data: status } = useQuery(
"git-status",
async () => {
const t = await getTransport();
return t.gitStatus ? t.gitStatus() : null;
},
{
enabled: mode === "dev",
refetchInterval: 15_000,
staleTime: 10_000,
},
);
if (mode !== "dev") {
return null;
}
return (
<div className="git-strip">
<span className="text-muted">You are on branch</span>
<code>{status?.branch ?? "…"}</code>
{status?.hasUncommittedChanges ? (
<span className="dirty">
{status.changedFiles.length} changed file
{status.changedFiles.length === 1 ? "" : "s"} under <code>db/seeds/</code>
</span>
) : (
<span className="clean">No seeds changes made!</span>
)}
<span className="ms-auto text-muted">
You can edit files, and they will change the seed files on your system.
</span>
</div>
);
}
@@ -0,0 +1,78 @@
import { useIngest } from "#lib/ingest/IngestProvider";
import { useEffect, useState } from "react";
// Bottom-right toast that surfaces sqlite ingest progress from anywhere in
// the app. Stays pinned while ingesting; auto-hides ~1.5s after completion.
export function IngestToast() {
const { error, progress, ready } = useIngest();
const [visible, setVisible] = useState(false);
const [hiding, setHiding] = useState(false);
useEffect(() => {
if (error || (!ready && progress)) {
setVisible(true);
setHiding(false);
return;
}
if (ready && visible) {
setHiding(true);
const t = setTimeout(() => {
setVisible(false);
setHiding(false);
}, 1500);
return () => clearTimeout(t);
}
}, [error, progress, ready, visible]);
if (!visible) {return null;}
const pct =
progress && progress.total > 0
? Math.round(((progress.idx + 1) / progress.total) * 100)
: 0;
return (
<div
aria-live="polite"
className={`ingest-toast ${hiding ? "is-hiding" : ""} ${error ? "is-error" : ""}`}
role="status"
>
<div className="ingest-toast-head">
{error ? (
<>
<span className="ingest-dot dot-error" />
<strong>Ingest failed</strong>
</>
) : ready ? (
<>
<span className="ingest-dot dot-ok" />
<strong>Database ready</strong>
</>
) : (
<>
<span className="ingest-spinner" />
<strong>Loading seeds</strong>
<span className="ingest-count">
{progress ? `${progress.idx + 1}/${progress.total}` : ""}
</span>
</>
)}
</div>
{!ready && !error && progress ? (
<>
<div className="ingest-file mono">
<span className="ingest-file-name">{progress.name}</span>
<span className="ingest-file-meta">
{progress.cached ? "cached" : `${progress.rows.toLocaleString()} rows`}
</span>
</div>
<div className="ingest-bar">
<div className="ingest-bar-fill" style={{ width: `${pct}%` }} />
</div>
</>
) : null}
{error ? <div className="ingest-error-msg mono">{error}</div> : null}
</div>
);
}
@@ -0,0 +1,249 @@
import type { QueryResult } from "#lib/sqlite/types";
import { useMemo, useState } from "react";
// Threshold (in chars) above which a cell value is treated as a "blob" and
// gets its own scrollable monospace code box instead of an inline cell.
const LONG_VALUE_THRESHOLD = 80;
interface ResultTableProps {
result: QueryResult;
// Soft cap on rows rendered to keep the DOM cheap. Extra rows are counted
// in the footer. Defaults to 500.
maxRows?: number;
// Optional footnote shown under the table (e.g. "first 500 of 1234 rows").
footerNote?: React.ReactNode;
// Optional renderer for trailing action buttons per row. Wrap interactive
// elements in onClick={e => e.stopPropagation()} so they don't toggle row
// expansion.
rowActions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
// If provided, replaces the default pretty-printed JSON expansion with
// whatever this renders. In `toggle` mode, clicking the row shows/hides it.
// In `inline` mode, the detail is always shown under each data row (no
// expand column or click). Omit when you only have data rows.
expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
// `toggle` (default): chevron, click row to show detail. `inline`: always
// show detail under each row when `expandedContent` is set; data rows are
// not clickable. Used e.g. by Bulk to keep diffs visible without expanding.
rowDetailMode?: "inline" | "toggle";
}
// Shared, readable result table. Used by Query and Bulk pages.
//
// Design notes:
// - Cells wrap (`white-space: pre-wrap`) instead of ellipsising, so JSON
// and long text are actually visible.
// - Very long values get a scroll-capped code block, so one fat `raw`
// column doesn't blow up row height.
// - By default, clicking a row toggles a detail panel (see `rowDetailMode`).
// Pass `expandedContent` for custom content (e.g. a diff); otherwise the
// detail is the whole row as pretty-printed JSON.
// - `rowDetailMode="inline"` (with `expandedContent`) shows that detail
// under every row with no chevron or click — e.g. Bulk edit previews.
export function ResultTable({
result,
maxRows = 500,
footerNote,
rowActions,
expandedContent,
rowDetailMode = "toggle",
}: ResultTableProps) {
const visible = result.rows.slice(0, maxRows);
const hidden = result.rows.length - visible.length;
const inline = rowDetailMode === "inline" && Boolean(expandedContent);
// Heuristic: mark columns where any sampled value is long-ish as "wide",
// so we render them as blobs instead of trying to squeeze them into a cell.
const wideCols = useMemo(() => {
const set = new Set<number>();
const sample = visible.slice(0, 50);
for (let col = 0; col < result.columns.length; col++) {
for (const row of sample) {
const v = row[col];
if (typeof v === "string" && v.length > LONG_VALUE_THRESHOLD) {
set.add(col);
break;
}
}
}
return set;
}, [visible, result.columns.length]);
return (
<div className="result-wrap">
<div className="result-scroll">
<table className="result-table-v2">
<thead>
<tr>
{inline ? null : (
<th aria-label="expand row" className="result-col-expand" />
)}
{result.columns.map((c, i) => (
<th className={wideCols.has(i) ? "col-wide" : undefined} key={c}>
{c}
</th>
))}
{rowActions ? (
<th aria-label="actions" className="result-col-actions" />
) : null}
</tr>
</thead>
<tbody>
{visible.map((row, i) => (
<ResultRow
actions={rowActions}
columns={result.columns}
expandedContent={expandedContent}
inlineDetail={inline}
key={i}
row={row}
wideCols={wideCols}
/>
))}
</tbody>
</table>
</div>
{hidden > 0 ? (
<div className="result-foot">
Showing first {visible.length.toLocaleString()} of{" "}
{result.rows.length.toLocaleString()} rows
</div>
) : null}
{footerNote ? <div className="result-foot">{footerNote}</div> : null}
</div>
);
}
function ResultRow({
row,
columns,
wideCols,
actions,
expandedContent,
inlineDetail,
}: {
actions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
columns: string[];
expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
inlineDetail: boolean;
row: unknown[];
wideCols: Set<number>;
}) {
const [open, setOpen] = useState(false);
function toggle() {
setOpen((x) => !x);
}
const extraCols = actions ? 1 : 0;
const showPanel = inlineDetail || open;
const isInline = inlineDetail;
return (
<>
<tr
className={isInline ? undefined : "result-row-clickable"}
onClick={isInline ? undefined : toggle}
onKeyDown={
isInline
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {toggle();}
}
}
role={isInline ? undefined : "button"}
tabIndex={isInline ? undefined : 0}
>
{isInline ? null : (
<td className="result-col-expand">
<span
aria-label={open ? "Collapse row" : "Expand row"}
className={`result-expander ${open ? "is-open" : ""}`}
>
▸
</span>
</td>
)}
{row.map((v, j) => (
<ResultCell key={j} value={v} wide={wideCols.has(j)} />
))}
{actions ? (
<td
className="result-col-actions"
onClick={(e) => e.stopPropagation()}
>
{actions({ columns, row })}
</td>
) : null}
</tr>
{showPanel ? (
<tr className={isInline ? "result-expanded result-expanded-inline" : "result-expanded"}>
{isInline ? null : <td aria-hidden="true" />}
<td colSpan={columns.length + extraCols}>
{expandedContent ? (
expandedContent({ columns, row })
) : (
<pre className="result-row-json mono">{formatRowAsJson(row, columns)}</pre>
)}
</td>
</tr>
) : null}
</>
);
}
function ResultCell({ value, wide }: { value: unknown; wide: boolean }) {
if (value === null) {
return (
<td>
<em className="text-muted">NULL</em>
</td>
);
}
const str = typeof value === "string" ? value : String(value);
const looksJson =
typeof value === "string" && str.length > 2 && (str.startsWith("{") || str.startsWith("["));
if (wide || str.length > LONG_VALUE_THRESHOLD || looksJson) {
return (
<td className="cell-blob">
<pre className="cell-pre mono">{looksJson ? pretty(str) : str}</pre>
</td>
);
}
return <td>{str}</td>;
}
function pretty(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function formatRowAsJson(row: unknown[], columns: string[]): string {
const obj: Record<string, unknown> = {};
for (let i = 0; i < columns.length; i++) {
const k = columns[i]!;
const v = row[i];
if (typeof v === "string") {
// If the value itself is a JSON string (like the `raw` column),
// nest the parsed value so the row-json reads naturally.
if (v.length > 2 && (v.startsWith("{") || v.startsWith("["))) {
try {
obj[k] = JSON.parse(v);
continue;
} catch {
// fall through
}
}
obj[k] = v;
} else {
obj[k] = v;
}
}
return JSON.stringify(obj, null, 2);
}
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { type z } from "zod";
// ---------------------------------------------------------------------------
// JSON textarea editor with live Zod validation.
//
// Replaces the previous schema-driven field-tree form. Seeds documents are
// complex enough (discriminated unions, per-game chart data, etc.) that a
// generated form was always going to be leaky — it's cleaner to just edit
// the raw JSON and let the schema tell you exactly what's wrong.
//
// Behaviour:
// - Textarea is pre-filled with the initial value as pretty-printed JSON.
// - On every keystroke we try JSON.parse; if it succeeds we run safeParse
// against the Zod schema and show any issues inline.
// - Save is disabled until the JSON is both valid JSON and passes the schema.
// ---------------------------------------------------------------------------
interface RowEditorProps {
schema: z.ZodType<unknown>;
initial: unknown;
onSave: (value: unknown) => void;
onCancel: () => void;
title?: string;
submitLabel?: string;
}
export function RowEditor({
schema,
initial,
onSave,
onCancel,
title,
submitLabel = "Save",
}: RowEditorProps) {
const [text, setText] = useState(() => safeStringify(initial));
const [parseError, setParseError] = useState<string | null>(null);
const [zodErrors, setZodErrors] = useState<string[]>([]);
const [parsed, setParsed] = useState<unknown>(initial);
useEffect(() => {
setText(safeStringify(initial));
setParseError(null);
setZodErrors([]);
setParsed(initial);
}, [initial]);
function handleChange(s: string) {
setText(s);
try {
const v = JSON.parse(s);
setParseError(null);
setParsed(v);
const result = schema.safeParse(v);
if (result.success) {
setZodErrors([]);
} else {
setZodErrors(
result.error.issues.map(
(issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
),
);
}
} catch (e) {
setParseError(e instanceof Error ? e.message : String(e));
setZodErrors([]);
setParsed(undefined);
}
}
function handleSave() {
const result = schema.safeParse(parsed);
if (!result.success) {return;}
onSave(result.data);
}
const isValid = !parseError && zodErrors.length === 0 && parsed !== undefined;
return (
<div className="row-editor">
{title ? <h4 className="row-editor-title">{title}</h4> : null}
<div className="row-editor-body">
<textarea
className="form-control mono"
onChange={(e) => handleChange(e.target.value)}
spellCheck={false}
style={{ minHeight: 420, resize: "vertical", fontSize: "0.82rem" }}
value={text}
/>
{parseError ? (
<div className="row-editor-error alert alert-danger mono">{parseError}</div>
) : zodErrors.length > 0 ? (
<div className="row-editor-error alert alert-warning mono">
{zodErrors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
) : (
<div className="text-success mono" style={{ fontSize: "0.78rem" }}>
✓ valid
</div>
)}
</div>
<div className="row-editor-actions">
<button className="btn btn-outline-secondary" onClick={onCancel} type="button">
Cancel
</button>
<button
className="btn btn-primary"
disabled={!isValid}
onClick={handleSave}
type="button"
>
{submitLabel}
</button>
</div>
</div>
);
}
function safeStringify(v: unknown): string {
if (v === undefined) {return "";}
try {
return JSON.stringify(v, null, 2);
} catch {
return "";
}
}
@@ -0,0 +1,22 @@
import type { RunEvent } from "#lib/transport/transport";
export function RunOutput({ events }: { events: RunEvent[] }) {
return (
<pre aria-live="polite" className="run-output">
{events.map((e, i) => {
if (e.kind === "exit") {
return (
<div className={e.code === 0 ? "text-success" : "text-danger"} key={i}>
{"\n"}[exit {e.code}]
</div>
);
}
return (
<span className={e.kind === "stderr" ? "stderr" : undefined} key={i}>
{e.data}
</span>
);
})}
</pre>
);
}
@@ -0,0 +1,56 @@
export type SchemaPanelProps = {
className?: string;
schema: Record<string, string[]> | null;
};
/**
* Read-only list of ingested SQLite tables and their columns (for reference while writing SQL).
*/
export function SchemaPanel({ className, schema }: SchemaPanelProps) {
if (schema === null) {
return (
<div className={className}>
<span className="schema-panel-hint small">Loading schema…</span>
</div>
);
}
const tables = Object.keys(schema).sort((a, b) => a.localeCompare(b));
if (tables.length === 0) {
return (
<div className={className}>
<span className="schema-panel-hint small">
No tables yet. Ingest seeds from the overview, then available tables will appear
here.
</span>
</div>
);
}
return (
<div className={className}>
<div className="schema-panel-title small mb-2">Available tables</div>
<div className="schema-panel-scroll">
<ul className="list-unstyled mb-0 small schema-tables">
{tables.map((table) => {
const columns = schema[table] ?? [];
return (
<li className="schema-table mb-2" key={table}>
<div className="mono text-body fw-medium">{table}</div>
{columns.length > 0 ? (
<ul className="list-unstyled schema-table-columns">
{columns.map((col) => (
<li className="text-break" key={col}>
{col}
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,241 @@
import { sql, SQLite } from "@codemirror/lang-sql";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { Compartment, EditorState } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { basicSetup, EditorView } from "codemirror";
import { tags } from "@lezer/highlight";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Replaces the default CodeMirror colors in dark mode — `defaultHighlightStyle` uses #708
* (purple) for keywords, which is nearly unreadable on the seeds-webui near-black page.
* When `themeType: "dark"`, this becomes the only non-fallback highlighter, so the purple
* palette is not used.
*/
const sqlEditorHighlightDark = HighlightStyle.define(
[
{ tag: tags.keyword, color: "#2dd4bf" },
{ tag: tags.typeName, color: "#22d3ee" },
{ tag: tags.name, color: "#e2e8f0" },
{ tag: tags.number, color: "#38bdf8" },
{ tag: tags.string, color: "#fbbf24" },
{ tag: tags.bool, color: "#4ade80" },
{ tag: tags.null, color: "#4ade80" },
{ tag: tags.lineComment, color: "#64748b" },
{ tag: tags.blockComment, color: "#64748b" },
{ tag: tags.comment, color: "#64748b" },
{ tag: tags.operator, color: "#a1a1aa" },
{ tag: tags.punctuation, color: "#9ca3af" },
{ tag: tags.paren, color: "#cbd5e1" },
{ tag: tags.brace, color: "#cbd5e1" },
{ tag: tags.squareBracket, color: "#cbd5e1" },
],
{ themeType: "dark" },
);
/** `data-bs-theme` on <html> — must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */
function useBootstrapPageDark(): boolean {
const [isDark, setIsDark] = useState(
() => document.documentElement.getAttribute("data-bs-theme") !== "light",
);
const sync = useCallback(() => {
setIsDark(document.documentElement.getAttribute("data-bs-theme") !== "light");
}, []);
useEffect(() => {
const el = document.documentElement;
const obs = new MutationObserver(sync);
obs.observe(el, { attributeFilter: ["data-bs-theme"], attributes: true });
return () => {
obs.disconnect();
};
}, [sync]);
return isDark;
}
export type SqlEditorProps = {
className?: string;
onChange: (next: string) => void;
onRun: () => void;
/** Table name → column names; drives SQL autocomplete. */
schema: Record<string, string[]> | null;
value: string;
};
/** Surfaces, caret, and panels — all use Bootstrap CSS variables so light/dark track the page. */
// Use baseTheme: `&light` / `&dark` are only expanded in buildTheme(..., lightDarkIDs), not in EditorView.theme().
const surfaceTheme = EditorView.baseTheme(
{
"&": {
fontSize: "0.9375rem",
backgroundColor: "var(--bs-body-bg)",
color: "var(--bs-body-color)",
fontVariantLigatures: "none",
fontFeatureSettings: '"liga" 0, "calt" 0',
},
// Caret: drawSelection defaults to black / #ddd for &dark, but the editor was not getting
// `EditorView.darkTheme` — keep an explicit color so the caret always matches body text.
".cm-cursor, .cm-dropCursor": {
borderLeft: "1.2px solid var(--bs-body-color)",
},
".cm-content": {
caretColor: "var(--bs-body-color)",
fontFamily: "var(--sw-mono)",
minHeight: "220px",
paddingBlock: "0.5rem",
},
// Override drawSelection’s fixed &light / &gray colors (see @codemirror/view).
"&light .cm-selectionBackground": {
background: "rgba(var(--bs-primary-rgb), 0.2)",
},
"&dark .cm-selectionBackground": {
background: "rgba(var(--bs-primary-rgb), 0.2)",
},
"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
background: "rgba(var(--bs-primary-rgb), 0.28)",
},
"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
background: "rgba(var(--bs-primary-rgb), 0.28)",
},
".cm-scroller": {
border: "1px solid var(--bs-border-color)",
borderRadius: "var(--bs-border-radius)",
overflow: "auto",
},
"&light .cm-gutters, &dark .cm-gutters": {
backgroundColor: "var(--bs-secondary-bg)",
borderRight: "1px solid var(--bs-border-color)",
borderRadius: "0",
color: "var(--bs-secondary-color)",
borderTopLeftRadius: "var(--bs-border-radius)",
borderBottomLeftRadius: "var(--bs-border-radius)",
},
"&light .cm-activeLineGutter, &dark .cm-activeLineGutter": {
backgroundColor: "var(--bs-tertiary-bg)",
},
"&light .cm-activeLine, &dark .cm-activeLine": {
backgroundColor: "var(--bs-tertiary-bg)",
},
".cm-panels, .cm-panels-top, .cm-panels-bottom": {
backgroundColor: "var(--bs-body-bg)",
color: "var(--bs-body-color)",
},
".cm-tooltip": {
backgroundColor: "var(--bs-body-bg)",
border: "1px solid var(--bs-border-color)",
boxShadow: "var(--bs-box-shadow)",
color: "var(--bs-body-color)",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: "var(--sw-mono)",
fontSize: "0.875rem",
},
".cm-tooltip-autocomplete ul li[aria-selected]": {
background: "var(--bs-primary)",
color: "#fff",
},
},
);
function sqlSupportForSchema(schema: Record<string, string[]> | null) {
const s = schema ?? {};
return sql({
dialect: SQLite,
schema: s as Record<string, readonly string[]>,
upperCaseKeywords: false,
});
}
export function SqlEditor({ className, onChange, onRun, schema, value }: SqlEditorProps) {
const isPageDark = useBootstrapPageDark();
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const darkConf = useRef(new Compartment());
const sqlConf = useRef(new Compartment());
const onChangeRef = useRef(onChange);
const onRunRef = useRef(onRun);
onChangeRef.current = onChange;
onRunRef.current = onRun;
useEffect(() => {
const host = hostRef.current;
if (!host) {
return;
}
const state = EditorState.create({
doc: value,
extensions: [
basicSetup,
darkConf.current.of(EditorView.darkTheme.of(isPageDark)),
syntaxHighlighting(sqlEditorHighlightDark),
surfaceTheme,
sqlConf.current.of(sqlSupportForSchema(schema)),
keymap.of([
{
key: "Mod-Enter",
run: () => {
onRunRef.current();
return true;
},
},
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
],
});
const view = new EditorView({ state, parent: host });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once; schema/value/dark synced below
}, []);
// Keep CodeMirror’s `dark` facet in sync with Bootstrap (caret, selection, completion UI).
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: darkConf.current.reconfigure(EditorView.darkTheme.of(isPageDark)),
});
}, [isPageDark]);
// Keep document in sync when `value` is updated externally (e.g. reset).
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const cur = view.state.doc.toString();
if (cur === value) {
return;
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}, [value]);
// Refresh SQL language + completions when schema changes.
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: sqlConf.current.reconfigure(sqlSupportForSchema(schema)),
});
}, [schema]);
return <div className={className} ref={hostRef} />;
}
@@ -0,0 +1,71 @@
import { useIngest } from "#lib/ingest/IngestProvider";
import type { ReactNode } from "react";
type SqliteWorkspaceGateProps = { children: ReactNode };
/**
* Renders the SQLite-driven workspace (CodeMirror, schema, run results) only after
* ingest finished. While transport or seeds load, shows a full-width placeholder
* that matches the editor area so the rest of the app (nav, other routes) stays usable.
*/
export function SqliteWorkspaceGate({ children }: SqliteWorkspaceGateProps) {
const {
error: ingestError,
progress,
ready,
transportError,
transportLoading,
} = useIngest();
if (ready) {
return <>{children}</>;
}
const errMsg = transportError ?? ingestError;
const pct =
progress && progress.total > 0
? Math.round(((progress.idx + 1) / progress.total) * 100)
: 0;
return (
<div aria-busy="true" className="sql-workspace-gate" role="status">
{errMsg ? (
<div className="alert alert-danger sql-workspace-gate-inner mono mb-0" role="alert">
<strong className="d-block text-danger-emphasis mb-1">SQLite not available</strong>
{errMsg}
</div>
) : (
<div className="sql-workspace-gate-inner">
<div className="d-flex align-items-center gap-2 flex-wrap">
<span
aria-hidden="true"
className="spinner-border spinner-border-sm text-primary"
/>
<strong>Loading seeds into SQLite</strong>
</div>
{transportLoading ? (
<p className="text-muted small mb-2">Connecting to seeds data…</p>
) : null}
{progress ? (
<>
<div className="d-flex small text-muted flex-wrap align-items-baseline gap-2 mb-1">
<span className="mono text-body-secondary">{progress.name}</span>
<span>
{progress.idx + 1} / {progress.total}
{progress.cached ? " · cached" : ` · ${progress.rows.toLocaleString()} rows`}
</span>
</div>
{progress.total > 0 ? (
<div className="ingest-bar sql-workspace-gate-bar" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Ingest progress">
<div className="ingest-bar-fill" style={{ width: `${pct}%` }} />
</div>
) : null}
</>
) : !transportLoading ? (
<p className="text-muted small mb-0">Preparing the database…</p>
) : null}
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
declare module "*.scss" {
const css: string;
export default css;
}
declare module "*.css" {
const css: string;
export default css;
}
interface ImportMetaEnv {
readonly VITE_SEEDS_EDIT_MODE?: boolean | string;
readonly VITE_SEEDS_REPO?: string;
readonly VITE_SEEDS_BRANCH?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+41
View File
@@ -0,0 +1,41 @@
// Whether edit-mode code paths are compiled in at all.
//
// This is a *build-time* flag set in vite.config.ts. In a production build
// (`vite build`) it is the literal `false`, so the bundler tree-shakes every
// branch guarded by `EDIT_MODE`. That's how we keep the static prod bundle
// absolutely incapable of writing to disk — even if someone self-hosted it.
//
// `hasDevTransport` (see #lib/transport/transport) additionally probes
// /__seeds/ping at runtime before enabling the edit UI, in case a dev-built
// bundle is served from somewhere unexpected.
export const EDIT_MODE =
(import.meta.env.VITE_SEEDS_EDIT_MODE as unknown as boolean | string) === true ||
import.meta.env.VITE_SEEDS_EDIT_MODE === "true";
// Target repo for the GitHub transport (used in prod and as a fallback in dev).
// Format: "owner/repo". Configured via VITE_SEEDS_REPO in vite.config.ts.
export const SEEDS_REPO = (import.meta.env.VITE_SEEDS_REPO as string | undefined) ?? "zkldi/tachi";
export const SEEDS_DEFAULT_BRANCH =
(import.meta.env.VITE_SEEDS_BRANCH as string | undefined) ?? "main";
// Path of the seeds directory inside the repo. Matches DEFAULT_SEEDS_DIR in
// typescript/server/src/test-utils/seed-paths.ts.
export const SEEDS_REPO_PATH = "db/seeds";
// Key used for storing the user's optional GitHub PAT in localStorage.
// Only referenced by the GitHub transport; never sent anywhere except api.github.com.
export const GITHUB_PAT_KEY = "seeds-webui:github-pat";
// Well-known collection file names, mirrored from tachi-common's DatabaseSeedNames.
// We keep a separate list here so the webui can enumerate files *without*
// pulling in tachi-common's game-config graph at runtime.
// (We cross-check this against DatabaseSeedNames in a unit test later.)
export const KNOWN_COLLECTIONS = [
"bms-course-lookup.json",
"folders.json",
"goals.json",
"questlines.json",
"quests.json",
"tables.json",
] as const;
@@ -0,0 +1,200 @@
/**
* Per-collection diff: compare two document arrays (used by the Diff page
* page) or a single before/after document pair (e.g. Drafts).
*/
import { prettySeedDocSummary } from "#lib/format/seed-doc-summary";
import { primaryKey, type Row, rowLabel } from "./row-primary-key";
export type { Row } from "./row-primary-key";
export { primaryKey, rowLabel } from "./row-primary-key";
// Deep-equality via JSON roundtrip. Good enough for seeds documents.
function sameJson(a: unknown, b: unknown): boolean {
if (a === b) {return true;}
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
export type FieldDelta =
| { after: unknown; before: unknown; kind: "changed"; path: string }
| { after: unknown; kind: "added"; path: string }
| { before: unknown; kind: "removed"; path: string };
export function fieldDeltas(a: Row, b: Row, prefix = ""): FieldDelta[] {
const out: FieldDelta[] = [];
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
for (const k of keys) {
const path = prefix ? `${prefix}.${k}` : k;
const av = a?.[k];
const bv = b?.[k];
if (sameJson(av, bv)) {continue;}
if (av === undefined) {
out.push({ after: bv, kind: "added", path });
continue;
}
if (bv === undefined) {
out.push({ before: av, kind: "removed", path });
continue;
}
// Recurse into plain objects (but not arrays — arrays are treated as
// atomic scalar values, which is the right call for things like
// `tierlistData` or chart `data`).
if (
typeof av === "object" &&
typeof bv === "object" &&
av !== null &&
bv !== null &&
!Array.isArray(av) &&
!Array.isArray(bv)
) {
out.push(...fieldDeltas(av as Row, bv as Row, path));
continue;
}
out.push({ after: bv, before: av, kind: "changed", path });
}
return out;
}
export type DiffRow =
| { after: Row; before: Row; fields: FieldDelta[]; id: string; kind: "changed"; pretty: string }
| { after: Row; id: string; kind: "added"; pretty: string }
| { before: Row; id: string; kind: "removed"; pretty: string };
export interface DiffSummary {
added: number;
changed: number;
removed: number;
rows: DiffRow[];
}
export interface SummariseDiffContext {
collectionName?: string;
songById?: Map<string, Row> | null;
}
function prettyForRow(
collectionName: string | undefined,
doc: Row,
songById: Map<string, Row> | null | undefined,
): string {
if (!collectionName) {
return rowLabel(doc);
}
return prettySeedDocSummary(collectionName, doc, { songById });
}
export function summariseDiff(
a: unknown[],
b: unknown[],
ctx?: SummariseDiffContext,
): DiffSummary {
const aMap = new Map<string, Row>();
const bMap = new Map<string, Row>();
for (const r of a) {
const pk = primaryKey(r as Row);
if (pk) {aMap.set(pk, r as Row);}
}
for (const r of b) {
const pk = primaryKey(r as Row);
if (pk) {bMap.set(pk, r as Row);}
}
let added = 0;
let removed = 0;
let changed = 0;
const rows: DiffRow[] = [];
const { collectionName, songById } = ctx ?? {};
for (const [k, v] of bMap) {
const prev = aMap.get(k);
if (!prev) {
added++;
rows.push({
after: v,
id: k,
kind: "added",
pretty: prettyForRow(collectionName, v, songById),
});
} else if (!sameJson(prev, v)) {
changed++;
rows.push({
after: v,
before: prev,
fields: fieldDeltas(prev, v),
id: k,
kind: "changed",
pretty: prettyForRow(collectionName, v, songById),
});
}
}
for (const [k, v] of aMap) {
if (!bMap.has(k)) {
removed++;
rows.push({
before: v,
id: k,
kind: "removed",
pretty: prettyForRow(collectionName, v, songById),
});
}
}
return { added, changed, removed, rows };
}
/**
* One-row diff: before and/or after are full documents. Used when side-stepping
* the primary-key index (e.g. a single draft op).
*/
export function singleDocumentDiff(
before: Row | null,
after: Row | null,
ctx?: SummariseDiffContext,
): DiffRow | null {
const { collectionName, songById } = ctx ?? {};
if (before && !after) {
return {
before,
id: rowLabel(before),
kind: "removed",
pretty: prettyForRow(collectionName, before, songById),
};
}
if (!before && after) {
return {
after,
id: rowLabel(after),
kind: "added",
pretty: prettyForRow(collectionName, after, songById),
};
}
if (before && after) {
if (sameJson(before, after)) {return null;}
return {
after,
before,
fields: fieldDeltas(before, after),
id: rowLabel(before) || rowLabel(after),
kind: "changed",
pretty: prettyForRow(collectionName, after, songById),
};
}
return null;
}
export function formatValue(v: unknown): string {
if (v === undefined) {return "—";}
if (v === null) {return "null";}
if (typeof v === "string") {return v;}
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
}
@@ -0,0 +1,27 @@
export type Row = Record<string, unknown>;
// Candidate primary keys, in priority order. The first one present on a row is
// used. `id` is listed before `songID` because chart documents carry both —
// we want to key a chart by *its* id, not by the song it belongs to.
export const PK_KEYS = [
"id",
"folderID",
"tableID",
"questID",
"questlineID",
"goalID",
"songID",
"md5sums",
] as const;
export function primaryKey(row: Row): string | null {
for (const k of PK_KEYS) {
const v = row[k];
if (typeof v === "string" || typeof v === "number") {return `${k}=${v}`;}
}
return null;
}
export function rowLabel(row: Row): string {
return primaryKey(row) ?? "(row)";
}
@@ -0,0 +1,39 @@
import type { z } from "zod";
import { mergeToPatchOps } from "#lib/edits/patch-merge-ops";
import { applyPatch, deepClone, type Operation } from "fast-json-patch";
/** Same merged document as apply-time JSON Patch expansion on a one-row array. */
export function mergedDocumentAfterBulkMerge(
baseRow: Record<string, unknown>,
patch: Record<string, unknown>,
): Record<string, unknown> {
const doc = deepClone([baseRow]) as unknown[];
const ops = mergeToPatchOps(baseRow, 0, patch) as Operation[];
const { newDocument } = applyPatch(doc, ops, true, false);
return newDocument[0] as Record<string, unknown>;
}
function formatZodIssues(err: z.ZodError): string {
return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
}
export function validateBulkMergeRow(
schema: z.ZodType<unknown>,
baseRow: Record<string, unknown>,
patch: Record<string, unknown>,
): { message: string; ok: false } | { ok: true } {
try {
const merged = mergedDocumentAfterBulkMerge(baseRow, patch);
const r = schema.safeParse(merged);
if (r.success) {
return { ok: true };
}
return { message: formatZodIssues(r.error), ok: false };
} catch (e) {
return {
message: e instanceof Error ? e.message : String(e),
ok: false,
};
}
}
@@ -0,0 +1,109 @@
import type { JsonPatch, JsonPatchOp } from "#lib/transport/transport";
// IndexedDB-backed draft store.
//
// A "draft" is a single JsonPatch op against one collection. The
// DraftsDrawer presents them grouped by collection and lets the user
// Apply -> transport.writeCollection(name, composedPatch) or Discard.
const DB_NAME = "seeds-webui";
const STORE = "drafts";
const DB_VERSION = 1;
export interface Draft {
id: string;
collection: string;
op: JsonPatchOp;
createdAt: number;
label?: string;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: "id" });
store.createIndex("collection", "collection", { unique: false });
}
};
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
});
}
function genId(): string {
// crypto.randomUUID is available in all modern browsers; fall back to a
// time+random composite if not (e.g. some test environments).
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export async function addDraft(draft: Omit<Draft, "createdAt" | "id">): Promise<Draft> {
const db = await openDb();
const full: Draft = { ...draft, createdAt: Date.now(), id: genId() };
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(full);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return full;
}
export async function listDrafts(): Promise<Draft[]> {
const db = await openDb();
return new Promise<Draft[]>((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).getAll();
req.onerror = () => reject(req.error);
req.onsuccess = () =>
resolve((req.result as Draft[]).sort((a, b) => a.createdAt - b.createdAt));
});
}
export async function removeDraft(id: string): Promise<void> {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function clearDrafts(collection?: string): Promise<void> {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
if (collection === undefined) {
store.clear();
} else {
const idx = store.index("collection");
const req = idx.openCursor(IDBKeyRange.only(collection));
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export function composeDrafts(drafts: Draft[]): Map<string, JsonPatch> {
const grouped = new Map<string, JsonPatch>();
for (const d of drafts) {
const bucket = grouped.get(d.collection) ?? [];
bucket.push(d.op);
grouped.set(d.collection, bucket);
}
return grouped;
}
@@ -0,0 +1,113 @@
import type { JsonPatchOp } from "#lib/transport/index";
export function jsonPtrEncode(s: string): string {
return s.replace(/~/gu, "~0").replace(/\//gu, "~1");
}
export function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
export function hasLeafKey(doc: unknown, pathToLeaf: string[]): boolean {
if (pathToLeaf.length === 0) {
return false;
}
let cur: unknown = doc;
for (let i = 0; i < pathToLeaf.length - 1; i++) {
const s = pathToLeaf[i]!;
if (!isPlainObject(cur) || !(s in cur)) {
return false;
}
cur = cur[s];
}
const last = pathToLeaf[pathToLeaf.length - 1]!;
return isPlainObject(cur) && last in cur;
}
export function getAtPath(doc: unknown, pathToLeaf: string[]): unknown {
let cur: unknown = doc;
for (const s of pathToLeaf) {
if (!isPlainObject(cur)) {
return undefined;
}
cur = cur[s];
}
return cur;
}
/** Deep merge `patch` into a shallow copy of `base` (plain objects merge recursively; scalars/arrays replace). */
export function applyMergeToRow(
base: Record<string, unknown>,
patch: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...base };
for (const [k, v] of Object.entries(patch)) {
if (isPlainObject(v) && isPlainObject(out[k])) {
out[k] = applyMergeToRow(out[k] as Record<string, unknown>, v);
} else {
out[k] = v;
}
}
return out;
}
/** Set or delete a value at a nested key path. Returns a deep-cloned object. */
export function setValueAtPath(
doc: Record<string, unknown>,
pathToLeaf: string[],
value: unknown,
): Record<string, unknown> {
if (pathToLeaf.length === 0) {
return structuredClone(doc) as Record<string, unknown>;
}
const out = structuredClone(doc) as Record<string, unknown>;
let cur: Record<string, unknown> = out;
for (let i = 0; i < pathToLeaf.length - 1; i++) {
const s = pathToLeaf[i]!;
const next = cur[s];
if (!isPlainObject(next)) {
throw new Error("setValueAtPath: path crosses a non-object");
}
cur = cur[s] as Record<string, unknown>;
}
const last = pathToLeaf[pathToLeaf.length - 1]!;
if (value === undefined) {
// Keep behavior aligned with “missing key” in merge ops (caller usually passes a value).
delete cur[last];
} else {
cur[last] = value;
}
return out;
}
/** Leaf paths for deep merge: plain objects recurse; arrays and other values are one op. */
export function flattenMergeLeaves(
merge: Record<string, unknown>,
prefix: string[],
): Array<{ path: string[]; value: unknown }> {
const out: Array<{ path: string[]; value: unknown }> = [];
for (const [k, v] of Object.entries(merge)) {
const path = [...prefix, k];
if (isPlainObject(v) && Object.keys(v).length > 0) {
out.push(...flattenMergeLeaves(v, path));
} else if (isPlainObject(v)) {
// Empty object: no scalar to write at this branch.
} else {
out.push({ path, value: v });
}
}
return out;
}
export function mergeToPatchOps(
row: Record<string, unknown>,
idx: number,
patchObj: Record<string, unknown>,
): JsonPatchOp[] {
const leaves = flattenMergeLeaves(patchObj, []);
return leaves.map(({ path, value }) => ({
op: hasLeafKey(row, path) ? "replace" : "add",
path: `/${idx}/${path.map(jsonPtrEncode).join("/")}`,
value,
} satisfies JsonPatchOp));
}
@@ -0,0 +1,55 @@
// Resolve a zod schema for a seeds collection by filename.
//
// Collections are named after the on-disk files in db/seeds/, e.g.
// `charts-iidx-sp.json`, `songs-iidx.json`, `folders.json`. Charts are
// per-game and songs are per-game-group, so we parse the filename to pick
// the right schema out of the per-game maps in tachi-common.
//
// If no schema is known for a collection (or the parsed game isn't in the
// maps), returns null. The caller should fall back to a raw-JSON editor.
import type { z } from "zod";
import {
SEEDS_BMS_COURSE_DOCUMENT_SCHEMA,
SEEDS_CHART_DOCUMENT_SCHEMAS,
SEEDS_FOLDER_DOCUMENT_SCHEMA,
SEEDS_GOAL_DOCUMENT_SCHEMA,
SEEDS_QUEST_DOCUMENT_SCHEMA,
SEEDS_QUESTLINE_DOCUMENT_SCHEMA,
SEEDS_SONG_DOCUMENT_SCHEMAS,
SEEDS_TABLE_DOCUMENT_SCHEMA,
} from "tachi-common/types/seeds-documents-zod";
export function schemaForCollection(name: string): z.ZodType<unknown> | null {
const stripped = name.replace(/\.json$/u, "");
if (stripped === "folders") {return SEEDS_FOLDER_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
if (stripped === "tables") {return SEEDS_TABLE_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
if (stripped === "goals") {return SEEDS_GOAL_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
if (stripped === "quests") {return SEEDS_QUEST_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
if (stripped === "questlines") {
return SEEDS_QUESTLINE_DOCUMENT_SCHEMA as z.ZodType<unknown>;
}
if (stripped === "bms-course-lookup") {
return SEEDS_BMS_COURSE_DOCUMENT_SCHEMA as z.ZodType<unknown>;
}
// charts-${V3Game}.json — filenames mirror `db/seeds/` and match ALL_GAMES
// keys (e.g. iidx-sp, bms-14k, gitadora-dora). Do not split on the last
// hyphen; that would turn iidx-sp into iidx and miss the schema map.
if (stripped.startsWith("charts-")) {
const game = stripped.slice("charts-".length);
const schemas = SEEDS_CHART_DOCUMENT_SCHEMAS as Record<string, z.ZodType<unknown>>;
return schemas[game] ?? null;
}
// songs-${gameGroup}.json
if (stripped.startsWith("songs-")) {
const group = stripped.slice("songs-".length);
const schemas = SEEDS_SONG_DOCUMENT_SCHEMAS as Record<string, z.ZodType<unknown>>;
return schemas[group] ?? null;
}
return null;
}
@@ -0,0 +1,87 @@
import type { V3Game } from "tachi-common/types";
import { primaryKey, type Row } from "#lib/diff/row-primary-key";
import { type Flavour, flavourFor } from "#lib/sqlite/schema";
import { FormatChart } from "tachi-common/utils/util";
export type SeedDocSummaryOpts = {
/** Lookup by song `id` when `songDoc` is not provided (git diff / drafts). */
songById?: Map<string, Row> | null;
/** For `charts-*` collections, joined song document (e.g. from SQLite join). */
songDoc?: Record<string, unknown> | null;
};
/**
* Human-readable one-liner for a seeds row — same rules as the collection
* browser list (FormatChart for charts with a song, title — artist for songs,
* etc.).
*/
export function prettySeedDocSummary(
collectionName: string,
doc: Row,
opts?: SeedDocSummaryOpts,
): string {
let flav: Flavour;
try {
flav = flavourFor(collectionName);
} catch {
return primaryKey(doc) ?? "(unknown)";
}
const songFromOpts = opts?.songDoc;
const songFromMap =
opts?.songById && typeof doc.songID === "string" ? opts.songById.get(doc.songID) : undefined;
const songDoc = songFromOpts ?? songFromMap;
switch (flav) {
case "charts": {
const game = collectionName
.replace(/^charts-/u, "")
.replace(/\.json$/u, "") as V3Game;
if (songDoc && game) {
try {
return FormatChart({ ...doc, game, song: songDoc } as unknown as Parameters<
typeof FormatChart
>[0]);
} catch {
// Fall through to simple format if FormatChart fails.
}
}
return [doc.difficulty, doc.level ? `Lv.${doc.level}` : null].filter(Boolean).join(" ");
}
case "songs": {
const title = str(doc.title);
const artist = str(doc.artist);
return artist ? `${title} — ${artist}` : title;
}
case "folders":
case "tables": {
const title = str(doc.title);
const tag = [doc.game, doc.playtype].filter(Boolean).join(" ");
return tag ? `${title} (${tag})` : title;
}
case "goals":
case "quests":
case "questlines": {
const nm = str(doc.name);
const tag = [doc.game, doc.playtype].filter(Boolean).join(" ");
return tag ? `${nm} (${tag})` : nm;
}
case "bms-course-lookup": {
const title = str(doc.title ?? doc.set);
const set = doc.set ? ` [${doc.set}]` : "";
return `${title}${set}`;
}
default:
return pkSummary(doc);
}
}
function str(v: unknown): string {
return typeof v === "string" ? v : String(v ?? "");
}
function pkSummary(row: Row): string {
const pk = primaryKey(row);
return pk ?? "(unknown)";
}
@@ -0,0 +1,34 @@
import type { V3Game } from "tachi-common/types";
import { type Row } from "#lib/diff/row-primary-key";
import { fetchCollection } from "#lib/transport/collection-cache";
import { useMemo } from "react";
import { useQuery } from "react-query";
import { GameToGameGroup } from "tachi-common/config/config";
/**
* Loads `songs-${group}.json` (working tree) and indexes by song `id`, for
* chart rows that need a song join to format with {@link prettySeedDocSummary}.
*/
export function useSongByIdLookup(collectionName: string | undefined) {
const songFile = useMemo(() => {
if (!collectionName?.startsWith("charts-")) {return null;}
const game = collectionName.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game;
return `songs-${GameToGameGroup(game)}.json`;
}, [collectionName]);
return useQuery(
["song-by-id-lookup", songFile],
async () => {
const data = await fetchCollection(songFile!);
const m = new Map<string, Row>();
for (const r of data as Record<string, unknown>[]) {
if (typeof r.id === "string") {
m.set(r.id, r as Row);
}
}
return m;
},
{ enabled: !!songFile, staleTime: 60_000 },
);
}
@@ -0,0 +1,77 @@
import { type BuildProgress, buildSqliteFromTransport } from "#lib/sqlite/builder";
import { getTransport } from "#lib/transport/transport";
import React, { createContext, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
interface IngestState {
progress: BuildProgress | null;
ready: boolean;
/** True once all collections have been ingested (or found cached) at least once this session. */
everReady: boolean;
/** Ingest of seeds into the worker DB failed. */
error: string | null;
/** `getTransport` still resolving (ingest not started). */
transportLoading: boolean;
/** Transport layer could not be created; SQLite is unavailable. */
transportError: string | null;
}
const IngestCtx = createContext<IngestState>({
error: null,
everReady: false,
progress: null,
ready: false,
transportError: null,
transportLoading: true,
});
export function useIngest(): IngestState {
return useContext(IngestCtx);
}
export function IngestProvider({ children }: { children: React.ReactNode }) {
const transportQuery = useQuery("transport", getTransport, { staleTime: Infinity });
const transportLoading = transportQuery.isLoading;
const transportError = transportQuery.isError
? String(
transportQuery.error instanceof Error
? transportQuery.error.message
: transportQuery.error,
)
: null;
const [progress, setProgress] = useState<BuildProgress | null>(null);
const [ready, setReady] = useState(false);
const [everReady, setEverReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!transportQuery.data) {return;}
let cancelled = false;
setReady(false);
setError(null);
(async () => {
for await (const p of buildSqliteFromTransport(transportQuery.data!)) {
if (cancelled) {return;}
setProgress(p);
}
if (!cancelled) {
setReady(true);
setEverReady(true);
}
})().catch((err) => {
console.error("[seeds-webui] ingest failed:", err);
if (!cancelled) {setError(String(err?.message ?? err));}
});
return () => {
cancelled = true;
};
}, [transportQuery.data]);
return (
<IngestCtx.Provider
value={{ error, everReady, progress, ready, transportError, transportLoading }}
>
{children}
</IngestCtx.Provider>
);
}
@@ -0,0 +1,49 @@
import type { SeedsTransport } from "#lib/transport/transport";
import { getSqlite } from "./client";
// Fast content hash for JSON payloads. We hash the serialised string because
// getCollection returns parsed JSON; this matches what scripts/bundle-current-seeds
// stores (sha256(prefix-16) of the raw file bytes — close enough for change detection).
async function hashJson(value: unknown): Promise<string> {
const s = JSON.stringify(value);
const buf = new TextEncoder().encode(s);
const digest = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(digest).slice(0, 8)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export interface BuildProgress {
name: string;
idx: number;
total: number;
rows: number;
cached: boolean;
}
export async function* buildSqliteFromTransport(
transport: SeedsTransport,
rev?: string,
): AsyncGenerator<BuildProgress> {
const sqlite = getSqlite();
await sqlite.init();
const names = await transport.listCollections();
const meta = rev === undefined ? await sqlite.getMeta() : {};
for (let i = 0; i < names.length; i++) {
const name = names[i]!;
const rows = await transport.getCollection(name, rev);
const hash = await hashJson(rows);
// When rev !== undefined we *always* re-ingest — the caller is looking at
// a specific commit, not the working copy, and we don't cache historical
// loads to avoid OPFS bloat.
if (rev === undefined && meta[name] === hash) {
yield { cached: true, idx: i, name, rows: rows.length, total: names.length };
continue;
}
await sqlite.ingest(name, rows, rev === undefined ? hash : `rev:${rev}`);
yield { cached: false, idx: i, name, rows: rows.length, total: names.length };
}
}
@@ -0,0 +1,19 @@
import * as Comlink from "comlink";
import type { SqliteApi } from "./types";
// Lazy-singleton wrapping the SQLite worker. The worker file lives at
// ./worker.ts; Vite handles the `new Worker(new URL(...), { type: "module" })`
// idiom to emit it as a separate chunk.
let remote: Comlink.Remote<SqliteApi> | null = null;
export function getSqlite(): Comlink.Remote<SqliteApi> {
if (remote) {return remote;}
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: "seeds-webui-sqlite",
});
remote = Comlink.wrap<SqliteApi>(worker);
void remote.init();
return remote;
}
@@ -0,0 +1,183 @@
// Canonical SQL schema for the seeds SQLite db.
//
// Strategy: *per-collection* tables mirror the on-disk files exactly
// (so a row in `charts_iidx_sp` corresponds 1:1 to a JSON object in
// `db/seeds/charts-iidx-sp.json`). We project the scalar fields we
// query on into columns and keep the rest as a JSON blob — SQLite's
// `json_extract` makes that queryable when needed.
//
// Cross-cutting questions ("how many charts does iidx-sp have across all
// versions?") get answered by materialised views built over per-collection
// tables. We build the union of charts/songs lazily — i.e. the view DDL
// references every `charts_<game>` table that has been created.
//
// Table name convention: lowercase, '-' -> '_', strip '.json'.
export function tableNameFor(collection: string): string {
return collection.replace(/\.json$/u, "").replace(/-/gu, "_");
}
// Schemas are table-flavoured: each collection maps to one of these.
//
// "songs" -> songs-*.json
// "charts" -> charts-*.json
// "folders" -> folders.json
// "goals" -> goals.json
// "quests" -> quests.json
// "questlines" -> questlines.json
// "tables" -> tables.json
// "bms-course-lookup" -> bms-course-lookup.json
export type Flavour =
| "bms-course-lookup"
| "charts"
| "folders"
| "goals"
| "questlines"
| "quests"
| "songs"
| "tables";
export function flavourFor(collection: string): Flavour {
if (collection.startsWith("songs-")) {return "songs";}
if (collection.startsWith("charts-")) {return "charts";}
if (collection === "folders.json") {return "folders";}
if (collection === "goals.json") {return "goals";}
if (collection === "quests.json") {return "quests";}
if (collection === "questlines.json") {return "questlines";}
if (collection === "tables.json") {return "tables";}
if (collection === "bms-course-lookup.json") {return "bms-course-lookup";}
throw new Error(`Unknown seeds collection flavour: ${collection}`);
}
// Columns that each flavour exposes as real columns (everything else goes
// into the `raw` JSON blob). Chosen to cover the common queries:
// filter by game/playtype/difficulty/level, join song<->chart.
export const FLAVOUR_COLUMNS: Record<Flavour, string[]> = {
"bms-course-lookup": ["md5sums", "game", "set", "value", "title"],
charts: [
"id",
"legacyChartID",
"songID",
"playtype",
"difficulty",
"level",
"levelNum",
"isPrimary",
"versions",
],
folders: ["id", "legacyFolderID", "game", "playtype", "title", "table", "type", "inactive"],
goals: ["goalID", "name", "criteria"],
questlines: ["questlineID", "game", "playtype", "name"],
quests: ["questID", "game", "playtype", "name"],
songs: ["id", "title", "artist"],
tables: [
"id",
"legacyTableID",
"game",
"playtype",
"title",
"default",
"inactive",
"description",
],
};
// DDL for per-collection tables. `raw` is the full JSON object; the rest are
// extracted scalar projections for indexing. Columns beyond `raw` are declared
// as `JSON` when they are arrays/objects (e.g. chart.versions, goal.criteria)
// and otherwise as TEXT/INTEGER as appropriate. SQLite doesn't care about
// declared types for storage, but having them helps readability.
export function ddlFor(collection: string): string {
const tbl = tableNameFor(collection);
const flav = flavourFor(collection);
const cols = FLAVOUR_COLUMNS[flav];
// Primary key per flavour.
const pk = {
"bms-course-lookup": "md5sums",
charts: "id",
folders: "id",
goals: "goalID",
questlines: "questlineID",
quests: "questID",
songs: "id",
tables: "id",
}[flav];
const colDecls = cols
.map((c) => {
if (c === pk) {return `"${c}" PRIMARY KEY`;}
if (c === "versions" || c === "criteria") {return `"${c}" TEXT`;} // JSON-as-text
if (c === "isPrimary" || c === "inactive" || c === "default") {return `"${c}" INTEGER`;}
if (c === "level" || c === "levelNum" || c === "value") {return `"${c}"`;}
return `"${c}" TEXT`;
})
.join(", ");
return `CREATE TABLE IF NOT EXISTS "${tbl}" (${colDecls}, "raw" TEXT NOT NULL);`;
}
export function indexesFor(collection: string): string[] {
const tbl = tableNameFor(collection);
const flav = flavourFor(collection);
const idx: string[] = [];
if (flav === "charts") {
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_songID" ON "${tbl}"("songID");`);
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_legacy" ON "${tbl}"("legacyChartID");`);
idx.push(
`CREATE INDEX IF NOT EXISTS "idx_${tbl}_playtype_diff" ON "${tbl}"("playtype","difficulty");`,
);
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_level" ON "${tbl}"("levelNum");`);
}
if (flav === "songs") {
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_title" ON "${tbl}"("title");`);
}
if (flav === "folders" || flav === "tables" || flav === "quests" || flav === "questlines") {
idx.push(
`CREATE INDEX IF NOT EXISTS "idx_${tbl}_game_playtype" ON "${tbl}"("game","playtype");`,
);
}
return idx;
}
// Meta table tracks what we have ingested. Key is the collection filename,
// value is the sha256(prefix-16) of the JSON bytes.
export const META_DDL = `
CREATE TABLE IF NOT EXISTS "_meta" (
"name" TEXT PRIMARY KEY,
"content_hash" TEXT NOT NULL,
"rows" INTEGER NOT NULL,
"ingested_at" INTEGER NOT NULL
);
`;
// Row projection: given a JSON row and the flavour, produce the ordered values
// to bind to the INSERT (followed by the full JSON string as `raw`).
export function projectRow(collection: string, row: Record<string, unknown>): unknown[] {
const flav = flavourFor(collection);
const cols = FLAVOUR_COLUMNS[flav];
const values: unknown[] = [];
for (const c of cols) {
const v = row[c];
if (Array.isArray(v) || (v !== null && typeof v === "object")) {
values.push(JSON.stringify(v));
} else if (typeof v === "boolean") {
values.push(v ? 1 : 0);
} else if (v === undefined) {
values.push(null);
} else {
values.push(v);
}
}
values.push(JSON.stringify(row));
return values;
}
export function insertSqlFor(collection: string): string {
const tbl = tableNameFor(collection);
const flav = flavourFor(collection);
const cols = FLAVOUR_COLUMNS[flav];
const colList = [...cols, "raw"].map((c) => `"${c}"`).join(",");
const placeholders = cols.map(() => "?").join(",");
return `INSERT OR REPLACE INTO "${tbl}" (${colList}) VALUES (${placeholders}, ?)`;
}
@@ -0,0 +1,40 @@
// Public types for the SQLite worker API.
//
// The worker runs an @sqlite.org/sqlite-wasm OO1 instance backed by OPFS.
// It exposes the tiny API in `SqliteApi` via Comlink; the main thread never
// touches the raw sqlite handle.
export interface SqliteApi {
// Initialise the DB (open/create OPFS file, run DDL if missing).
init(): Promise<void>;
// Replace a collection's rows wholesale. Called by the builder after
// fetching a JSON collection via the transport. Idempotent: matching
// content_hash on _meta skips the work.
ingest(name: string, rows: unknown[], contentHash: string): Promise<{ ingested: boolean }>;
// Run a SELECT and return rows + column names.
query(sql: string, params?: unknown[]): Promise<QueryResult>;
// Run an INSERT/UPDATE/DELETE (for bulk-ops previews against a scratch DB).
// Not persisted; the UI composes JSON patches separately and calls
// transport.writeCollection for the real write.
exec(sql: string, params?: unknown[]): Promise<{ changes: number }>;
// Total row count per table.
tableCounts(): Promise<Array<{ name: string; rows: number }>>;
// Table name -> column names (from PRAGMA table_info), for query UI hints.
getSchema(): Promise<Record<string, string[]>>;
// Current manifest of (collection, contentHash) pairs we have ingested.
// Used by the builder to decide what to re-ingest.
getMeta(): Promise<Record<string, string>>;
}
export interface QueryResult {
columns: string[];
rows: unknown[][];
// Elapsed wall-clock in the worker, ms.
elapsedMs: number;
}
@@ -0,0 +1,217 @@
/// <reference lib="webworker" />
import sqlite3InitModule, {
type Database,
type SqlValue,
} from "@sqlite.org/sqlite-wasm";
import * as Comlink from "comlink";
import type { QueryResult, SqliteApi } from "./types";
import {
ddlFor,
type flavourFor,
indexesFor,
insertSqlFor,
META_DDL,
projectRow,
tableNameFor,
} from "./schema";
// Single OPFS-backed database file. Versioned via SQLite's `user_version`
// pragma so future schema bumps can wipe cleanly.
//
// Bump history:
// 1 initial schema.
// 2 charts: PK changed from `chartID` to `id`, `legacyChartID` added.
// 3 folders/tables: PK changed to `id`, `legacyFolderID`/`legacyTableID`
// added.
const DB_PATH = "/seeds-webui.sqlite3";
const SCHEMA_VERSION = 3;
let db: Database | null = null;
async function ensureDb(): Promise<Database> {
if (db) {return db;}
const sqlite3 = await sqlite3InitModule();
if (typeof sqlite3.oo1.OpfsDb === "function") {
// `OpfsDb` requires the page to be cross-origin isolated (COOP/COEP).
// If it fails at construction time we fall through to a transient in-memory db.
try {
db = new sqlite3.oo1.OpfsDb(DB_PATH, "c");
} catch (err) {
console.warn("[seeds-webui] OPFS unavailable, falling back to :memory:", err);
db = new sqlite3.oo1.DB(":memory:", "c");
}
} else {
db = new sqlite3.oo1.DB(":memory:", "c");
}
initialise(db);
return db;
}
function initialise(handle: Database) {
handle.exec("PRAGMA journal_mode = MEMORY;");
handle.exec("PRAGMA synchronous = NORMAL;");
handle.exec(META_DDL);
// Inspect the stored user_version. If it's older than what this build
// expects, drop every non-meta table so the next ingest rebuilds them
// from fresh DDL. Necessary because `CREATE TABLE IF NOT EXISTS` won't
// modify an existing table's columns / PK.
const current = selectAll(handle, "PRAGMA user_version;").rows[0]?.[0] as number | undefined;
if (current === undefined || current < SCHEMA_VERSION) {
console.info(
`[seeds-webui] sqlite schema upgrade ${current ?? 0} -> ${SCHEMA_VERSION}; wiping cached tables`,
);
const tables = selectAll(
handle,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\'",
);
handle.exec("BEGIN");
try {
for (const [tbl] of tables.rows) {
handle.exec(`DROP TABLE IF EXISTS "${tbl as string}"`);
}
handle.exec(`DELETE FROM _meta`);
handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
handle.exec("COMMIT");
} catch (err) {
handle.exec("ROLLBACK");
throw err;
}
}
}
function selectAll(handle: Database, sql: string, params: readonly SqlValue[] = []): QueryResult {
const t0 = performance.now();
const stmt = handle.prepare(sql);
try {
if (params.length > 0) {stmt.bind(params);}
const columns = stmt.getColumnNames();
const rows: unknown[][] = [];
while (stmt.step()) {
rows.push(stmt.get([]) as unknown[]);
}
return { columns, elapsedMs: performance.now() - t0, rows };
} finally {
stmt.finalize();
}
}
// SQLite only accepts scalar bindings; the UI hands us `unknown[]` so we
// coerce here (objects/arrays get JSON.stringified).
function toBindings(xs: unknown[]): SqlValue[] {
return xs.map((v) => {
if (v === undefined || v === null) {return null;}
if (typeof v === "boolean") {return v ? 1 : 0;}
if (typeof v === "string" || typeof v === "number" || typeof v === "bigint") {return v;}
if (v instanceof Uint8Array) {return v;}
return JSON.stringify(v);
});
}
const api: SqliteApi = {
async init() {
await ensureDb();
},
async ingest(name, rows, contentHash) {
const handle = await ensureDb();
const prev = selectAll(handle, `SELECT content_hash FROM _meta WHERE name = ?`, [name]);
const prevHash = prev.rows[0]?.[0] as string | undefined;
if (prevHash === contentHash) {return { ingested: false };}
const tbl = tableNameFor(name);
handle.exec("BEGIN");
try {
handle.exec(ddlFor(name));
for (const idx of indexesFor(name)) {handle.exec(idx);}
handle.exec(`DELETE FROM "${tbl}"`);
const stmt = handle.prepare(insertSqlFor(name));
try {
for (const row of rows) {
if (row === null || typeof row !== "object") {continue;}
stmt.bind(toBindings(projectRow(name, row as Record<string, unknown>)));
stmt.step();
stmt.reset(true);
}
} finally {
stmt.finalize();
}
handle.exec({
bind: [name, contentHash, rows.length, Date.now()],
sql: `INSERT OR REPLACE INTO _meta (name, content_hash, rows, ingested_at) VALUES (?, ?, ?, ?)`,
});
handle.exec("COMMIT");
} catch (err) {
handle.exec("ROLLBACK");
throw err;
}
return { ingested: true };
},
async query(sql, params = []) {
const handle = await ensureDb();
return selectAll(handle, sql, toBindings(params));
},
async exec(sql, params = []) {
const handle = await ensureDb();
handle.exec({ bind: toBindings(params), sql });
return { changes: 0 };
},
async tableCounts() {
const handle = await ensureDb();
const names = selectAll(
handle,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name`,
);
const out: Array<{ name: string; rows: number }> = [];
for (const [tbl] of names.rows) {
const r = selectAll(handle, `SELECT COUNT(*) FROM "${tbl as string}"`);
out.push({ name: tbl as string, rows: (r.rows[0]?.[0] ?? 0) as number });
}
return out;
},
async getSchema() {
const handle = await ensureDb();
const names = selectAll(
handle,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name`,
);
const out: Record<string, string[]> = {};
for (const [tbl] of names.rows) {
const t = tbl as string;
const info = selectAll(handle, `PRAGMA table_info("${t.replace(/"/gu, '""')}")`);
const cols: string[] = [];
for (const row of info.rows) {
const name = row[1];
if (typeof name === "string") {
cols.push(name);
}
}
out[t] = cols;
}
return out;
},
async getMeta() {
const handle = await ensureDb();
const r = selectAll(handle, `SELECT name, content_hash FROM _meta`);
const out: Record<string, string> = {};
for (const [n, h] of r.rows) {
out[n as string] = h as string;
}
return out;
},
};
// Silences unused-import on schema helpers that are referenced only inside
// the generated DDL strings.
export type __EnsureBundled = ReturnType<typeof flavourFor>;
Comlink.expose(api);
@@ -0,0 +1,28 @@
// Module-level cache for on-disk collection data.
//
// Shared across Drafts, Bulk-edit, and any other page that needs to load a
// collection to compute diffs. One network round-trip per collection name per
// page load. Call `bustCollection` / `bustAll` after writes so the next
// consumer sees fresh data.
import { getTransport } from "#lib/transport/transport";
const cache = new Map<string, Promise<unknown[]>>();
export function fetchCollection(name: string): Promise<unknown[]> {
if (!cache.has(name)) {
cache.set(
name,
getTransport().then((t) => t.getCollection(name) as Promise<unknown[]>),
);
}
return cache.get(name)!;
}
export function bustCollection(name: string): void {
cache.delete(name);
}
export function bustAll(): void {
cache.clear();
}
@@ -0,0 +1,112 @@
import type {
Branch,
CollectionName,
Commit,
CommitPage,
GitStatus,
JsonPatch,
RunEvent,
SeedsTransport,
} from "./index";
// Talks to the Vite dev plugin's /__seeds/* endpoints.
// Only instantiated when a runtime probe of /__seeds/ping succeeds.
const BASE = "/__seeds";
async function jsonFetch<T>(input: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${input}`, {
...init,
headers: {
"content-type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
throw new Error(`seeds-webui dev transport ${input}: HTTP ${res.status}`);
}
return (await res.json()) as T;
}
export async function probeDevTransport(): Promise<boolean> {
try {
const res = await fetch(`${BASE}/ping`, { method: "GET" });
return res.ok;
} catch {
return false;
}
}
export function makeDevTransport(): SeedsTransport {
return {
mode: "dev",
listCollections: () => jsonFetch<CollectionName[]>("/collections"),
listBranches: () => jsonFetch<{ branches: Branch[]; current: Branch | null }>("/branches"),
listCommits: (opts) => {
const params = new URLSearchParams();
if (opts.branch) {params.set("branch", opts.branch);}
if (opts.file) {params.set("file", opts.file);}
if (opts.cursor) {params.set("cursor", opts.cursor);}
return jsonFetch<CommitPage>(`/commits?${params.toString()}`);
},
getCommit: (sha) => jsonFetch<Commit>(`/commit?sha=${encodeURIComponent(sha)}`),
getCollection: (name, rev) => {
const params = new URLSearchParams({ name });
if (rev !== undefined) {params.set("rev", rev);}
return jsonFetch<unknown[]>(`/collection?${params.toString()}`);
},
writeCollection: (name, patch) =>
jsonFetch<void>("/collection", {
body: JSON.stringify({ name, patch } satisfies {
name: CollectionName;
patch: JsonPatch;
}),
method: "POST",
}),
runSort: () => jsonFetch<void>("/sort", { method: "POST" }),
gitStatus: () => jsonFetch<GitStatus>("/status"),
runTests: () => streamSSE<RunEvent>("/tests/run", {}),
};
}
// Server-Sent Events helper — the dev plugin streams stdout/stderr line-by-line
// plus a final `exit` event, so we wrap it as an AsyncIterable<RunEvent>.
async function* streamSSE<T>(pathname: string, body: unknown): AsyncIterable<T> {
const res = await fetch(`${BASE}${pathname}`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
method: "POST",
});
if (!res.body) {
throw new Error(`seeds-webui dev transport ${pathname}: no stream body`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {break;}
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line; events are `data: <json>\n`
let delim: number;
while ((delim = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, delim);
buffer = buffer.slice(delim + 2);
for (const line of frame.split("\n")) {
if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
if (payload.length === 0) {continue;}
yield JSON.parse(payload) as T;
}
}
}
}
} finally {
reader.releaseLock();
}
}
@@ -0,0 +1,170 @@
import { GITHUB_PAT_KEY, SEEDS_DEFAULT_BRANCH, SEEDS_REPO, SEEDS_REPO_PATH } from "#lib/config";
import type { Commit, SeedsTransport } from "./index";
// Read-only transport backed by the GitHub REST API and the bundled
// snapshot in /seeds-bundle/*.
//
// For "current state" queries we prefer the bundled snapshot (zero API
// calls, always fast). For historical queries we hit api.github.com.
// Users can paste a PAT into settings -> stored in localStorage -> used
// as `Authorization: Bearer <pat>` to lift the anonymous 60/hr quota
// to the 5000/hr authenticated one.
const GH = "https://api.github.com";
function ghHeaders(): HeadersInit {
const pat = typeof localStorage !== "undefined" ? localStorage.getItem(GITHUB_PAT_KEY) : null;
const headers: Record<string, string> = {
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
};
if (pat) {headers.authorization = `Bearer ${pat}`;}
return headers;
}
async function gh<T>(pathname: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${GH}${pathname}`, {
...init,
headers: { ...ghHeaders(), ...init?.headers },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`GitHub ${pathname}: HTTP ${res.status} ${text}`);
}
return (await res.json()) as T;
}
interface GhCommit {
sha: string;
html_url: string;
commit: {
author: { date: string; email: string; name: string };
committer: { date: string; email: string; name: string };
message: string;
};
parents: Array<{ sha: string }>;
}
function mapCommit(c: GhCommit): Commit {
return {
sha: c.sha,
message: c.commit.message,
author: {
name: c.commit.author.name,
email: c.commit.author.email,
date: c.commit.author.date,
},
committer: {
name: c.commit.committer.name,
email: c.commit.committer.email,
date: c.commit.committer.date,
},
parents: c.parents.map((p) => ({ sha: p.sha })),
htmlUrl: c.html_url,
};
}
// Parse Link header for cursor-style pagination.
function parseLinkCursor(link: string | null): string | undefined {
if (!link) {return undefined;}
// Example: <https://api.github.com/...&page=2>; rel="next"
const m = /<([^>]+)>; rel="next"/u.exec(link);
if (!m) {return undefined;}
const url = new URL(m[1]!);
return url.searchParams.get("page") ?? undefined;
}
interface BundleManifest {
sha?: string;
files: Array<{ bytes: number; hash: string; name: string }>;
}
async function loadBundleManifest(): Promise<BundleManifest | null> {
try {
const res = await fetch("/seeds-bundle/manifest.json");
if (!res.ok) {return null;}
return (await res.json()) as BundleManifest;
} catch {
return null;
}
}
export function makeGithubTransport(): SeedsTransport {
return {
mode: "github",
listCollections: async () => {
const m = await loadBundleManifest();
if (m) {return m.files.map((f) => f.name);}
// Fallback: list contents of db/seeds on the default branch.
interface Entry {
name: string;
type: "dir" | "file";
}
const items = await gh<Entry[]>(
`/repos/${SEEDS_REPO}/contents/${SEEDS_REPO_PATH}?ref=${SEEDS_DEFAULT_BRANCH}`,
);
return items
.filter((e) => e.type === "file" && e.name.endsWith(".json"))
.map((e) => e.name);
},
listBranches: async () => {
interface GhBranch {
name: string;
commit: { sha: string };
}
const list = await gh<GhBranch[]>(`/repos/${SEEDS_REPO}/branches?per_page=100`);
const branches = list.map((b) => ({ name: b.name, sha: b.commit.sha }));
const current = branches.find((b) => b.name === SEEDS_DEFAULT_BRANCH) ?? null;
return { branches, current };
},
listCommits: async (opts) => {
const params = new URLSearchParams();
params.set("sha", opts.branch ?? SEEDS_DEFAULT_BRANCH);
params.set("path", opts.file ? `${SEEDS_REPO_PATH}/${opts.file}` : SEEDS_REPO_PATH);
params.set("per_page", "30");
if (opts.cursor) {params.set("page", opts.cursor);}
const res = await fetch(`${GH}/repos/${SEEDS_REPO}/commits?${params.toString()}`, {
headers: ghHeaders(),
});
if (!res.ok) {
throw new Error(`GitHub /commits: HTTP ${res.status}`);
}
const nextCursor = parseLinkCursor(res.headers.get("link"));
const commits = ((await res.json()) as GhCommit[]).map(mapCommit);
return { commits, nextCursor };
},
getCommit: async (sha) => {
const c = await gh<GhCommit>(`/repos/${SEEDS_REPO}/commits/${sha}`);
return mapCommit(c);
},
getCollection: async (name, rev) => {
// Prefer the bundled snapshot for "current state" (no rev given).
if (rev === undefined) {
const res = await fetch(`/seeds-bundle/${encodeURIComponent(name)}`);
if (res.ok) {return (await res.json()) as unknown[];}
}
// Otherwise, fetch from raw.githubusercontent.com at the given ref.
// We go through raw.githubusercontent because /contents returns base64 of
// up to 1MB and charts-bms-7k etc. blow past that. This endpoint does
// not count against the API rate limit either.
const ref = rev ?? SEEDS_DEFAULT_BRANCH;
const url = `https://raw.githubusercontent.com/${SEEDS_REPO}/${encodeURIComponent(
ref,
)}/${SEEDS_REPO_PATH}/${encodeURIComponent(name)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`raw.githubusercontent ${name}@${ref}: HTTP ${res.status}`);
}
return (await res.json()) as unknown[];
},
};
}
@@ -0,0 +1,81 @@
// Transport abstraction: a single interface that lets the UI talk to seeds,
// regardless of whether we're running locally (with a dev server that can
// write to disk) or in prod (read-only, backed by the GitHub API).
//
// Edit methods (`writeCollection`, `runTests`, …) are *optional* on the
// interface. In prod they are `undefined`; every UI site that uses them must
// null-check, which is how read-only mode is enforced at the type level.
export type CollectionName = string;
export interface Branch {
name: string;
sha: string;
}
export interface Commit {
sha: string;
message: string;
author: {
date: string;
email?: string;
name: string;
};
committer?: {
date: string;
email?: string;
name: string;
};
parents: Array<{ sha: string }>;
// URL to view this commit (GitHub or local placeholder).
htmlUrl?: string;
}
export interface CommitPage {
commits: Commit[];
nextCursor?: string;
}
export interface GitStatus {
branch: string | null;
hasUncommittedChanges: boolean;
changedFiles: string[];
}
export type RunEvent =
| { code: number; kind: "exit" }
| { data: string; kind: "stderr" }
| { data: string; kind: "stdout" };
// JSON Patch op (RFC 6902 subset we support: add/remove/replace).
export type JsonPatchOp =
| { op: "add"; path: string; value: unknown }
| { op: "remove"; path: string }
| { op: "replace"; path: string; value: unknown };
export type JsonPatch = JsonPatchOp[];
export interface SeedsTransport {
readonly mode: "dev" | "github";
// Read-side — always available.
listCollections(): Promise<CollectionName[]>;
listBranches(): Promise<{ branches: Branch[]; current: Branch | null }>;
listCommits(opts: { branch?: string; cursor?: string; file?: string }): Promise<CommitPage>;
getCommit(sha: string): Promise<Commit>;
// rev `undefined` means "current working directory" in dev and "default
// branch HEAD" in github.
getCollection(name: CollectionName, rev?: string): Promise<unknown[]>;
// Write-side — populated only in dev mode.
writeCollection?: (name: CollectionName, patch: JsonPatch) => Promise<void>;
runSort?: () => Promise<void>;
gitStatus?: () => Promise<GitStatus>;
runTests?: () => AsyncIterable<RunEvent>;
}
export function isWritable(
t: SeedsTransport,
): t is Required<Pick<SeedsTransport, "writeCollection">> & SeedsTransport {
return typeof t.writeCollection === "function";
}
@@ -0,0 +1,47 @@
import { EDIT_MODE } from "#lib/config";
import type { SeedsTransport } from "./index";
import { makeDevTransport, probeDevTransport } from "./dev-transport";
import { makeGithubTransport } from "./github-transport";
// Resolves exactly once per page load.
let cached: Promise<SeedsTransport> | null = null;
/**
* The single entry point used by components/pages.
*
* - In a prod build (`EDIT_MODE === false`), this short-circuits to the GitHub
* transport: the dev-transport probe code path is literally tree-shaken out.
* - In a dev build, we probe `/__seeds/ping`. If it responds, edit UI is
* unlocked; otherwise we fall back to the GitHub transport (e.g. `vite
* preview` locally).
*/
export function getTransport(): Promise<SeedsTransport> {
if (cached) {return cached;}
cached = (async () => {
if (EDIT_MODE && (await probeDevTransport())) {
return makeDevTransport();
}
return makeGithubTransport();
})();
return cached;
}
export function resetTransport(): void {
cached = null;
}
export type {
Branch,
CollectionName,
Commit,
CommitPage,
GitStatus,
JsonPatch,
JsonPatchOp,
RunEvent,
SeedsTransport,
} from "./index";
+6
View File
@@ -0,0 +1,6 @@
import ReactDOM from "react-dom";
import App from "./App";
import "./styles/app.scss";
ReactDOM.render(<App />, document.getElementById("root"));
+407
View File
@@ -0,0 +1,407 @@
import type { QueryResult } from "#lib/sqlite/types";
import { SingleRowDocumentDiff } from "#components/CollectionDiffRows";
import { ResultTable } from "#components/ResultTable";
import { SqlEditor } from "#components/SqlEditor";
import { SqliteWorkspaceGate } from "#components/SqliteWorkspaceGate";
import { validateBulkMergeRow } from "#lib/edits/bulk-merge-validation";
import { addDraft } from "#lib/edits/draft-store";
import { applyMergeToRow } from "#lib/edits/patch-merge-ops";
import { schemaForCollection } from "#lib/edits/schemas";
import { useIngest } from "#lib/ingest/IngestProvider";
import { getSqlite } from "#lib/sqlite/client";
import { flavourFor, tableNameFor } from "#lib/sqlite/schema";
import { fetchCollection } from "#lib/transport/collection-cache";
import { useEffect, useRef, useState } from "react";
const SAFE_SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/u;
// Query-then-patch bulk editor. Flow:
// 1. Write a SELECT that finds rows you want to change (must include a
// primary-key-ish column).
// 2. Preview rows.
// 3. Enter a JSON object of fields to merge onto each matching row.
// 4. Review the generated patch ops; "Stage" to enqueue as drafts, then
// apply from the Drafts drawer.
const DEFAULT_BULK_SQL = `SELECT id FROM charts_iidx_sp WHERE levelNum >= 12;`;
export function Bulk() {
const { ready: sqliteReady } = useIngest();
const [sql, setSql] = useState(
() => localStorage.getItem("seeds-webui:bulk-preview-sql") ?? DEFAULT_BULK_SQL,
);
const sqlRef = useRef(sql);
sqlRef.current = sql;
const [schema, setSchema] = useState<Record<string, string[]> | null>(null);
const [collection, setCollection] = useState("charts-iidx-sp.json");
const [pkField, setPkField] = useState("id");
const [patchJson, setPatchJson] = useState(`{"level": "11", "data": {"badChart": true}}`);
const [preview, setPreview] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [staged, setStaged] = useState(0);
useEffect(() => {
if (!sqliteReady) {
return;
}
let cancelled = false;
void getSqlite()
.getSchema()
.then((s) => {
if (!cancelled) {
setSchema(s);
}
})
.catch(() => {
if (!cancelled) {
setSchema({});
}
});
return () => {
cancelled = true;
};
}, [sqliteReady]);
// Parse patch eagerly so we can detect syntax errors before staging.
const [patch, patchError] = useParsedPatch(patchJson);
async function runPreview() {
const text = sqlRef.current;
setError(null);
try {
setPreview(await getSqlite().query(text));
localStorage.setItem("seeds-webui:bulk-preview-sql", text);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function stage() {
if (!preview || !patch) {
return;
}
const pkIdx = preview.columns.indexOf(pkField);
if (pkIdx < 0) {
setError(`Primary-key column "${pkField}" not found in preview`);
return;
}
if (!SAFE_SQL_IDENT.test(pkField)) {
setError(
`Primary-key column must be a simple identifier (letters, digits, underscore).`,
);
return;
}
const zodSchema = schemaForCollection(collection);
if (!zodSchema) {
setError(
`No Zod schema for "${collection}". Bulk staging only allows collections with a known document type.`,
);
return;
}
let table: string;
try {
flavourFor(collection);
table = tableNameFor(collection);
} catch {
setError(`Unknown collection flavour for "${collection}".`);
return;
}
const pks: unknown[] = [];
for (const row of preview.rows) {
const pk = row[pkIdx];
if (pk !== null && pk !== undefined) {
pks.push(pk);
}
}
if (pks.length === 0) {
setError("No primary-key values in preview to stage.");
return;
}
const placeholders = pks.map(() => "?").join(", ");
let loaded: QueryResult;
try {
loaded = await getSqlite().query(
`SELECT "${pkField}", raw FROM "${table}" WHERE "${pkField}" IN (${placeholders})`,
pks,
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return;
}
const loadPkIdx = loaded.columns.indexOf(pkField);
const rawIdx = loaded.columns.indexOf("raw");
if (loadPkIdx < 0 || rawIdx < 0) {
setError("SQLite result missing pk or raw column.");
return;
}
const byPk = new Map<string, Record<string, unknown>>();
for (const lr of loaded.rows) {
const rawCell = lr[rawIdx];
if (typeof rawCell !== "string") {
continue;
}
try {
byPk.set(String(lr[loadPkIdx]), JSON.parse(rawCell) as Record<string, unknown>);
} catch {
setError(`Invalid JSON in raw for ${pkField}=${String(lr[loadPkIdx])}`);
return;
}
}
const rejections: string[] = [];
for (const row of preview.rows) {
const pk = row[pkIdx];
if (pk === null || pk === undefined) {
continue;
}
const pkStr = String(pk);
const base = byPk.get(pkStr);
if (!base) {
rejections.push(`${pkField}=${pkStr}: row not found in ${collection}`);
} else {
const v = validateBulkMergeRow(zodSchema, base, patch);
if (!v.ok) {
rejections.push(`${pkField}=${pkStr}: ${v.message}`);
}
}
if (rejections.length >= 12) {
rejections.push("(further errors omitted)");
break;
}
}
if (rejections.length > 0) {
setError(`Bulk merge rejected — would not satisfy schema:\n${rejections.join("\n")}`);
return;
}
// One synthetic merge op per row; Drafts expands to deep JSON Patch ops at apply-time.
let n = 0;
for (const row of preview.rows) {
const pk = row[pkIdx];
if (pk === null || pk === undefined) {
continue;
}
// eslint-disable-next-line no-await-in-loop
await addDraft({
collection,
label: `bulk merge ${pkField}=${String(pk)}`,
// Path: /~pk-marker~/<field> so the Drafts page can expand it
// at apply-time into real indices against the read JSON array.
op: {
op: "replace",
path: `/~by-${pkField}~/${encodeURIComponent(String(pk))}/__merge__`,
value: patch,
},
});
n++;
}
setStaged((s) => s + n);
}
return (
<div>
<h2 className="page-title">Bulk edit</h2>
<p className="page-subtitle">
Write a SELECT that returns primary keys. Provide a JSON patch. Stage the result as
drafts and review in the Drafts drawer before applying to disk.
</p>
<SqliteWorkspaceGate>
<label className="form-label">SELECT (preview query)</label>
<SqlEditor
className="mb-2"
onChange={(next) => {
sqlRef.current = next;
setSql(next);
}}
onRun={() => {
void runPreview();
}}
schema={schema}
value={sql}
/>
<div className="row g-2 mb-2">
<div className="col-md-6">
<label className="form-label">Target collection</label>
<input
className="form-control mono"
onChange={(e) => setCollection(e.target.value)}
value={collection}
/>
</div>
<div className="col-md-6">
<label className="form-label">Primary-key column in query</label>
<input
className="form-control mono"
onChange={(e) => setPkField(e.target.value)}
value={pkField}
/>
</div>
</div>
<label className="form-label">
Merge patch (JSON object, nested keys merge by path)
</label>
<textarea
className={`form-control mono mb-1 ${patchError ? "is-invalid" : ""}`}
onChange={(e) => setPatchJson(e.target.value)}
rows={4}
spellCheck={false}
value={patchJson}
/>
{patchError ? (
<div className="invalid-feedback mb-2">{patchError}</div>
) : (
<div className="mb-2" />
)}
<div className="d-flex gap-2 mb-3">
<button className="btn btn-outline-primary" onClick={() => void runPreview()}>
Preview (Ctrl-Enter)
</button>
<button
className="btn btn-warning"
disabled={!preview || preview.rows.length === 0 || !!patchError}
onClick={() => void stage()}
>
Stage {preview ? `${preview.rows.length} drafts` : ""}
</button>
{staged ? (
<span className="align-self-center text-muted">Staged {staged} so far</span>
) : null}
</div>
{error ? <div className="alert alert-danger mono">{error}</div> : null}
{preview ? (
<ResultTable
expandedContent={
patch
? ({ columns, row }) => (
<BulkRowDiff
collection={collection}
columns={columns}
patch={patch}
pkField={pkField}
row={row}
/>
)
: undefined
}
maxRows={200}
result={preview}
rowDetailMode="inline"
/>
) : null}
</SqliteWorkspaceGate>
</div>
);
}
// ---------------------------------------------------------------------------
// Diff preview for a single bulk-edit row.
// ---------------------------------------------------------------------------
function BulkRowDiff({
columns,
row,
pkField,
collection,
patch,
}: {
collection: string;
columns: string[];
patch: Record<string, unknown>;
pkField: string;
row: unknown[];
}) {
const [docPair, setDocPair] = useState<{
after: Record<string, unknown>;
before: Record<string, unknown>;
} | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const pkIdx = columns.indexOf(pkField);
const pkValue = pkIdx >= 0 ? String(row[pkIdx]) : null;
if (!pkValue) {
setErr(`Column "${pkField}" not found in query results.`);
setLoading(false);
return;
}
void fetchCollection(collection)
.then((data) => {
const current = (data as Record<string, unknown>[]).find(
(r) => String(r[pkField]) === pkValue,
);
if (!current) {
setErr(`No row found in ${collection} where ${pkField}=${pkValue}.`);
return;
}
setDocPair({
after: applyMergeToRow(current, patch),
before: current,
});
})
.catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
})
.finally(() => {
setLoading(false);
});
// Only run once per (collection, pkField, pkValue) combination.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (loading) {
return <div className="draft-diff-loading">Loading diff…</div>;
}
if (err) {
return <div className="draft-diff-error">{err}</div>;
}
if (!docPair) {
return <div className="draft-diff-loading">No diff available.</div>;
}
return (
<SingleRowDocumentDiff
after={docPair.after}
before={docPair.before}
collectionName={collection}
/>
);
}
// ---------------------------------------------------------------------------
// Helpers.
// ---------------------------------------------------------------------------
function useParsedPatch(json: string): [Record<string, unknown> | null, string | null] {
const [state, setState] = useState<[Record<string, unknown> | null, string | null]>(() =>
parsePatch(json),
);
useEffect(() => {
setState(parsePatch(json));
}, [json]);
return state;
}
function parsePatch(json: string): [Record<string, unknown> | null, string | null] {
try {
const v = JSON.parse(json);
if (v === null || typeof v !== "object" || Array.isArray(v)) {
return [null, "Patch must be a JSON object."];
}
return [v as Record<string, unknown>, null];
} catch (e) {
return [null, e instanceof Error ? e.message : String(e)];
}
}
@@ -0,0 +1,386 @@
import type { V3Game } from "tachi-common/types";
import type { z } from "zod";
import { RowEditor } from "#components/RowEditor";
import { EDIT_MODE } from "#lib/config";
import { PK_KEYS } from "#lib/diff/row-primary-key";
import { addDraft } from "#lib/edits/draft-store";
import { prettySeedDocSummary } from "#lib/format/seed-doc-summary";
import { useIngest } from "#lib/ingest/IngestProvider";
import { getSqlite } from "#lib/sqlite/client";
import { type Flavour, flavourFor, tableNameFor } from "#lib/sqlite/schema";
import { getTransport } from "#lib/transport/transport";
import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "react-query";
import { useParams } from "react-router-dom";
import { GameToGameGroup } from "tachi-common/config/config";
export function Collection() {
const { name } = useParams<{ name: string }>();
const [filter, setFilter] = useState("");
const [drawer, setDrawer] = useState<DrawerState | null>(null);
const { ready } = useIngest();
const qc = useQueryClient();
const table = tableNameFor(name);
const flav = useMemo<Flavour | null>(() => {
try {
return flavourFor(name);
} catch {
return null;
}
}, [name]);
// Lazy-loaded zod schema for the current collection. We only import the
// schemas module when EDIT_MODE is on so the prod bundle doesn't ship the
// whole tachi-common game config tree.
const [schema, setSchema] = useState<z.ZodType<unknown> | null>(null);
useEffect(() => {
if (!EDIT_MODE) {return;}
let alive = true;
import("#lib/edits/schemas")
.then((m) => {
if (alive) {setSchema(m.schemaForCollection(name));}
})
.catch((err) => {
console.error("[seeds-webui] failed to load schemas:", err);
});
return () => {
alive = false;
};
}, [name]);
// For chart collections we know the V3Game and can join the songs table.
const chartGame = useMemo<V3Game | null>(() => {
if (flav !== "charts") {return null;}
const game = name.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game;
return game;
}, [flav, name]);
const songTable = useMemo<string | null>(() => {
if (!chartGame) {return null;}
const group = GameToGameGroup(chartGame);
return tableNameFor(`songs-${group}.json`);
}, [chartGame]);
const rows = useQuery(
["collection-rows", table, filter, songTable],
async () => {
const params: unknown[] = filter ? [filter] : [];
if (flav === "charts" && songTable) {
// Join song title/artist so SeedDocItem can show meaningful info.
const where = filter ? `WHERE c.raw LIKE '%' || ? || '%'` : "";
return getSqlite().query(
`SELECT c.raw, s.raw FROM "${table}" c LEFT JOIN "${songTable}" s ON c.songID = s.id ${where}`,
params,
);
}
const where = filter ? `WHERE raw LIKE '%' || ? || '%'` : "";
return getSqlite().query(`SELECT raw FROM "${table}" ${where}`, params);
},
{ enabled: ready, keepPreviousData: true },
);
const entries = useMemo<SeedDocEntry[]>(() => {
if (!rows.data) {return [];}
const out: SeedDocEntry[] = [];
for (const row of rows.data.rows) {
const doc = safeParseRaw(row[0]);
if (!doc) {continue;}
if (flav === "charts" && chartGame) {
const songDoc = safeParseRaw(row[1]);
out.push({ doc, game: chartGame, songDoc: songDoc ?? undefined });
} else {
out.push({ doc });
}
}
return out;
}, [rows.data, flav, chartGame]);
function openEdit(doc: Record<string, unknown>) {
setDrawer({ initial: doc, mode: "edit" });
}
function openAdd() {
setDrawer({ initial: {}, mode: "add" });
}
async function stageReplace(before: Record<string, unknown>, after: unknown) {
const idx = await findRowIndex(name, before);
if (idx < 0) {throw new Error(`could not locate row in ${name}`);}
await addDraft({
collection: name,
label: summariseEntry(name, { doc: before }),
op: { op: "replace", path: `/${idx}`, value: after },
});
}
async function stageAdd(row: unknown) {
await addDraft({
collection: name,
label: "new row",
op: { op: "add", path: "/-", value: row },
});
}
async function stageDelete(entry: SeedDocEntry) {
const label = summariseEntry(name, entry);
if (!confirm(`Stage a delete for "${label}"?`)) {return;}
const idx = await findRowIndex(name, entry.doc);
if (idx < 0) {
alert(`Could not locate row in ${name}. It may already be gone.`);
return;
}
await addDraft({
collection: name,
label: `delete ${label}`,
op: { op: "remove", path: `/${idx}` },
});
}
async function handleDrawerSave(value: unknown) {
if (!drawer) {return;}
try {
if (drawer.mode === "edit") {
await stageReplace(drawer.initial as Record<string, unknown>, value);
} else {
await stageAdd(value);
}
setDrawer(null);
await qc.invalidateQueries(["collection-rows", table]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
}
return (
<div>
<h2 className="page-title mono">{name}</h2>
<div className="d-flex gap-2 mb-3 align-items-center flex-wrap">
<input
className="form-control"
onChange={(e) => setFilter(e.target.value)}
placeholder="filter (LIKE match against raw JSON)"
style={{ flex: 1, minWidth: 240 }}
value={filter}
/>
{EDIT_MODE ? (
<button
className="btn btn-primary"
disabled={!schema}
onClick={openAdd}
title={schema ? undefined : "No schema available for this collection"}
type="button"
>
+ Add row
</button>
) : null}
</div>
{!schema && EDIT_MODE ? (
<div className="alert alert-warning mono">
No zod schema found for this collection — editing is unavailable.
</div>
) : null}
{rows.isLoading ? (
<div className="text-muted">Loading…</div>
) : (
<SeedDocList
canEdit={EDIT_MODE && !!schema}
collectionName={name}
entries={entries}
onDelete={(entry) => void stageDelete(entry)}
onEdit={(entry) => openEdit(entry.doc)}
total={rows.data?.rows.length ?? 0}
/>
)}
{drawer && schema ? (
<Drawer onClose={() => setDrawer(null)}>
<RowEditor
initial={drawer.initial}
onCancel={() => setDrawer(null)}
onSave={(v) => void handleDrawerSave(v)}
schema={schema}
submitLabel={drawer.mode === "edit" ? "Stage change" : "Stage add"}
title={drawer.mode === "edit" ? "Edit row" : "Add row"}
/>
</Drawer>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// Data shapes
interface SeedDocEntry {
doc: Record<string, unknown>;
songDoc?: Record<string, unknown>;
game?: string;
}
function summariseEntry(collectionName: string, entry: SeedDocEntry): string {
return prettySeedDocSummary(collectionName, entry.doc, { songDoc: entry.songDoc });
}
// ---------------------------------------------------------------------------
// Document list
function SeedDocList({
entries,
total,
collectionName,
canEdit,
onEdit,
onDelete,
}: {
canEdit: boolean;
collectionName: string;
entries: SeedDocEntry[];
onDelete: (entry: SeedDocEntry) => void;
onEdit: (entry: SeedDocEntry) => void;
total: number;
}) {
if (entries.length === 0) {
return <div className="text-muted mono">No documents.</div>;
}
return (
<div className="seed-doc-list">
{entries.map((entry, i) => (
<SeedDocItem
canEdit={canEdit}
collectionName={collectionName}
entry={entry}
key={i}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
<div className="result-foot">{total.toLocaleString()} rows</div>
</div>
);
}
function SeedDocItem({
entry,
collectionName,
canEdit,
onEdit,
onDelete,
}: {
canEdit: boolean;
collectionName: string;
entry: SeedDocEntry;
onDelete: (entry: SeedDocEntry) => void;
onEdit: (entry: SeedDocEntry) => void;
}) {
const [open, setOpen] = useState(false);
const summary = summariseEntry(collectionName, entry);
return (
<div className="seed-doc-item">
<div
aria-expanded={open}
className="seed-doc-head"
onClick={() => setOpen((x) => !x)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((x) => !x);
}
}}
role="button"
tabIndex={0}
>
<span aria-hidden="true" className={`result-expander ${open ? "is-open" : ""}`}>
▸
</span>
<span className="seed-doc-summary mono">{summary}</span>
{canEdit ? (
<div
className="seed-doc-actions btn-group btn-group-sm"
onClick={(e) => e.stopPropagation()}
>
<button
className="btn btn-outline-primary"
onClick={() => onEdit(entry)}
type="button"
>
Edit
</button>
<button
className="btn btn-outline-danger"
onClick={() => onDelete(entry)}
type="button"
>
Delete
</button>
</div>
) : null}
</div>
{open ? (
<pre className="seed-doc-json mono">{JSON.stringify(entry.doc, null, 2)}</pre>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
type DrawerState = { initial: unknown; mode: "add" } | { initial: unknown; mode: "edit" };
function Drawer({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div className="drawer-backdrop" onMouseDown={onClose}>
<aside
aria-modal="true"
className="drawer"
onMouseDown={(e) => e.stopPropagation()}
role="dialog"
>
<button aria-label="Close" className="drawer-close" onClick={onClose} type="button">
×
</button>
{children}
</aside>
</div>
);
}
function safeParseRaw(v: unknown): Record<string, unknown> | null {
if (typeof v !== "string") {return null;}
try {
const parsed = JSON.parse(v);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// fall through
}
return null;
}
function pickPk(row: Record<string, unknown>): { key: string; value: number | string } | null {
for (const k of PK_KEYS) {
const v = row[k];
if (typeof v === "string" || typeof v === "number") {return { key: k, value: v };}
}
return null;
}
// Locate a row's index inside the on-disk collection. Fetches the current
// working-copy / HEAD content, then matches by primary key.
async function findRowIndex(name: string, row: Record<string, unknown>): Promise<number> {
const t = await getTransport();
const data = await t.getCollection(name);
const pk = pickPk(row);
if (!pk) {return -1;}
for (let i = 0; i < data.length; i++) {
const r = data[i] as Record<string, unknown> | undefined;
if (r && r[pk.key] === pk.value) {return i;}
}
return -1;
}
+386
View File
@@ -0,0 +1,386 @@
import type { V3Game } from "tachi-common/types";
import { DiffRows } from "#components/CollectionDiffRows";
import { type Row, summariseDiff } from "#lib/diff/collection-diff";
import { type Commit , getTransport } from "#lib/transport/transport";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "react-query";
import { Link, useLocation } from "react-router-dom";
import { GameToGameGroup } from "tachi-common/config/config";
function mergeSongRowsById(baseSongs: Row[], headSongs: Row[]): Map<string, Row> {
const m = new Map<string, Row>();
for (const r of baseSongs) {
if (typeof r.id === "string") {
m.set(r.id, r);
}
}
for (const r of headSongs) {
if (typeof r.id === "string") {
m.set(r.id, r);
}
}
return m;
}
// seeds.tachi.ac always points at the canonical upstream. We use this for
// every GitHub link in the diff header (commits, compare, PRs). This is a
// deliberate hardcode — the dev transport doesn't know what remote the
// local clone is pointing at, and for the hosted site it's always this repo.
const GITHUB_REPO_URL = "https://github.com/zkldi/Tachi";
function useQueryParams() {
const location = useLocation();
return useMemo(() => new URLSearchParams(location.search), [location.search]);
}
// ---------------------------------------------------------------------------
function CommitPicker() {
const [listFilter, setListFilter] = useState("");
const files = useQuery("collections", async () => (await getTransport()).listCollections());
const commits = useQuery(["commits", listFilter], async () => {
const t = await getTransport();
return t.listCommits({ file: listFilter || undefined });
});
return (
<>
<p className="page-subtitle">
Commits that touched <code>db/seeds</code> (or a specific file). Pick one to diff it
against its parent.
</p>
<div className="mb-3">
<label className="form-label">File filter</label>
<select
className="form-select"
onChange={(e) => setListFilter(e.target.value)}
value={listFilter}
>
<option value="">All seeds</option>
{(files.data ?? []).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div className="spacer-y">
{(commits.data?.commits ?? []).map((c) => (
<Link
className="row-link"
key={c.sha}
to={`/diff?base=${c.parents[0]?.sha ?? ""}&head=${c.sha}${
listFilter ? `&file=${encodeURIComponent(listFilter)}` : ""
}`}
>
<code className="sha">{c.sha.slice(0, 7)}</code>
<span className="subject">{c.message.split("\n")[0]}</span>
<span className="meta">
{c.author.name} · {new Date(c.author.date).toLocaleDateString()}
</span>
</Link>
))}
</div>
</>
);
}
export function Diff() {
const location = useLocation();
const params = useQueryParams();
const base = params.get("base") ?? "";
const head = params.get("head") ?? "";
const hasPair = Boolean(base && head);
const files = useQuery("collections", async () => (await getTransport()).listCollections());
const [file, setFile] = useState(() => {
const p = new URLSearchParams(location.search);
if (!p.get("base") || !p.get("head")) {return "folders.json";}
return p.get("file") ?? "folders.json";
});
const [filter, setFilter] = useState("");
const [kindFilter, setKindFilter] = useState<"added" | "all" | "changed" | "removed">("all");
useEffect(() => {
if (!hasPair) {return;}
const p = new URLSearchParams(location.search);
setFile(p.get("file") ?? "folders.json");
}, [hasPair, location.search]);
const headCommit = useQuery(
["commit", head],
async () => {
if (!head) {return null;}
const t = await getTransport();
return t.getCommit(head);
},
{ enabled: !!head, staleTime: 60_000 },
);
const pair = useQuery(
["pair", base, head, file],
async () => {
if (!base || !head || !file) {return null;}
const t = await getTransport();
const [a, b] = await Promise.all([
t.getCollection(file, base),
t.getCollection(file, head),
]);
let songById: Map<string, Row> | undefined;
if (file.startsWith("charts-")) {
const game = file.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game;
const songFile = `songs-${GameToGameGroup(game)}.json`;
const [sa, sb] = await Promise.all([
t.getCollection(songFile, base),
t.getCollection(songFile, head),
]);
songById = mergeSongRowsById(sa as Row[], sb as Row[]);
}
return { a, b, songById };
},
{ enabled: !!base && !!head && !!file },
);
const summary = useMemo(() => {
if (!pair.data) {return null;}
return summariseDiff(pair.data.a, pair.data.b, {
collectionName: file,
songById: pair.data.songById,
});
}, [pair.data, file]);
const filteredRows = useMemo(() => {
if (!summary) {return [];}
const q = filter.trim().toLowerCase();
return summary.rows.filter((r) => {
const kindOk =
kindFilter === "all" ||
(kindFilter === "added" && r.kind === "added") ||
(kindFilter === "removed" && r.kind === "removed") ||
(kindFilter === "changed" && r.kind === "changed");
if (!kindOk) {return false;}
if (!q) {return true;}
return (
r.id.toLowerCase().includes(q) || r.pretty.toLowerCase().includes(q)
);
});
}, [summary, filter, kindFilter]);
return (
<div>
<h2 className="page-title">Diff</h2>
{!hasPair ? (
<CommitPicker />
) : (
<>
<p className="mb-3">
<Link to="/diff">← Choose another commit</Link>
</p>
<CommitHeader
base={base}
commit={headCommit.data ?? null}
head={head}
loading={headCommit.isLoading}
/>
<div className="diff-controls mb-3">
<div className="control">
<label className="form-label">Collection</label>
<select
className="form-select"
onChange={(e) => setFile(e.target.value)}
value={file}
>
{(files.data ?? []).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div className="control control-grow">
<label className="form-label">Filter rows</label>
<input
className="form-control"
onChange={(e) => setFilter(e.target.value)}
placeholder="matches primary key (id=…) or formatted label"
value={filter}
/>
</div>
<div className="control">
<label className="form-label">Show</label>
<div className="btn-group" role="group">
{(["all", "added", "removed", "changed"] as const).map((k) => (
<button
className={`btn btn-sm ${
kindFilter === k
? "btn-primary"
: "btn-outline-secondary"
}`}
key={k}
onClick={() => setKindFilter(k)}
type="button"
>
{k}
</button>
))}
</div>
</div>
</div>
{pair.isLoading ? <p>Loading…</p> : null}
{pair.isError ? (
<p className="text-danger">Failed to load pair: {String(pair.error)}</p>
) : null}
{summary ? (
<div className="diff-badges mb-3">
<span className="badge bg-success">+{summary.added} added</span>
<span className="badge bg-danger">−{summary.removed} removed</span>
<span className="badge bg-warning text-dark">
~{summary.changed} changed
</span>
<span className="diff-badges-meta">
showing {filteredRows.length.toLocaleString()} of{" "}
{summary.rows.length.toLocaleString()}
</span>
</div>
) : null}
{summary ? <DiffRows rows={filteredRows} /> : null}
</>
)}
</div>
);
}
// Look for "(#123)" or "#123" in a commit message (squash-merge / GitHub auto-
// references). Falls back to the "Merge pull request #123" style from merge
// commits. Returns a PR number or null.
function extractPrNumber(message: string): number | null {
const parens = message.match(/\(#(\d+)\)/u);
if (parens?.[1]) {return Number(parens[1]);}
const merge = message.match(/Merge pull request #(\d+)/u);
if (merge?.[1]) {return Number(merge[1]);}
const hash = message.match(/(?:^|\s)#(\d+)(?:\s|$)/mu);
if (hash?.[1]) {return Number(hash[1]);}
return null;
}
function formatCommitDate(iso: string | undefined): string {
if (!iso) {return "";}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {return iso;}
return d.toLocaleString(undefined, {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "short",
year: "numeric",
});
}
function CommitHeader({
base,
head,
commit,
loading,
}: {
base: string;
commit: Commit | null;
head: string;
loading: boolean;
}) {
const subject = commit?.message.split("\n")[0] ?? "";
const body = commit?.message.split("\n").slice(1).join("\n").trim() ?? "";
const prNumber = commit ? extractPrNumber(commit.message) : null;
const commitUrl = `${GITHUB_REPO_URL}/commit/${head}`;
const compareUrl = `${GITHUB_REPO_URL}/compare/${base}...${head}`;
const prUrl = prNumber ? `${GITHUB_REPO_URL}/pull/${prNumber}` : null;
return (
<div className="commit-header mb-4">
<div className="commit-header-main">
<div aria-hidden="true" className="commit-header-avatar">
{(commit?.author.name ?? "?").slice(0, 1).toUpperCase()}
</div>
<div className="commit-header-body">
<div className="commit-header-subject">
{loading ? "Loading commit…" : subject || <em>(no message)</em>}
</div>
<div className="commit-header-meta">
<span>
<strong>{commit?.author.name ?? "—"}</strong>
{commit?.author.email ? (
<span className="text-muted"> &lt;{commit.author.email}&gt;</span>
) : null}
</span>
<span className="commit-header-dot">·</span>
<span>{formatCommitDate(commit?.author.date)}</span>
{commit?.committer && commit.committer.email !== commit.author.email ? (
<>
<span className="commit-header-dot">·</span>
<span className="text-muted">
committed by {commit.committer.name}
</span>
</>
) : null}
</div>
{body ? <pre className="commit-header-body-text">{body}</pre> : null}
</div>
</div>
<div className="commit-header-refs mono">
<span className="commit-ref">
<span className="label">base</span>
<a href={`${GITHUB_REPO_URL}/commit/${base}`} rel="noreferrer" target="_blank">
<code>{base.slice(0, 7)}</code>
</a>
</span>
<span className="commit-arrow">→</span>
<span className="commit-ref">
<span className="label">head</span>
<a href={commitUrl} rel="noreferrer" target="_blank">
<code>{head.slice(0, 7)}</code>
</a>
</span>
</div>
<div className="commit-header-actions">
{prUrl ? (
<a
className="btn btn-sm btn-primary"
href={prUrl}
rel="noreferrer"
target="_blank"
>
View PR #{prNumber}
</a>
) : null}
<a
className="btn btn-sm btn-outline-secondary"
href={commitUrl}
rel="noreferrer"
target="_blank"
>
View commit on GitHub
</a>
<a
className="btn btn-sm btn-outline-secondary"
href={compareUrl}
rel="noreferrer"
target="_blank"
>
Compare {base.slice(0, 7)}…{head.slice(0, 7)}
</a>
</div>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { SingleRowDocumentDiff } from "#components/CollectionDiffRows";
import {
clearDrafts,
composeDrafts,
type Draft,
listDrafts,
removeDraft,
} from "#lib/edits/draft-store";
import { applyMergeToRow, mergeToPatchOps, setValueAtPath } from "#lib/edits/patch-merge-ops";
import {
bustAll,
bustCollection,
fetchCollection,
} from "#lib/transport/collection-cache";
import {
getTransport,
type JsonPatch,
type JsonPatchOp,
type SeedsTransport,
} from "#lib/transport/transport";
import { useCallback, useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Build before/after document pair for a draft op (same shape as the commit
// diff “collection diff” row cards, via SingleRowDocumentDiff).
// ---------------------------------------------------------------------------
type DocPair = {
after: Record<string, unknown> | null;
before: Record<string, unknown> | null;
};
async function computeDocPair(draft: Draft): Promise<DocPair | null> {
const { op, collection } = draft;
if (op.op === "add") {
return { after: op.value as Record<string, unknown>, before: null };
}
if (op.op === "remove") {
const data = await fetchCollection(collection);
const synth = /^\/~by-([^/]+)~\/([^/]+)/u.exec(op.path);
if (synth) {
const [, field, enc] = synth;
const val = decodeURIComponent(enc!);
const row = (data as Record<string, unknown>[]).find(
(r) => String(r[field!]) === val,
);
return { after: null, before: row ?? null };
}
const idxM = /^\/(\d+)/u.exec(op.path);
if (idxM) {
return {
after: null,
before: (data[parseInt(idxM[1]!, 10)] as Record<string, unknown>) ?? null,
};
}
return { after: null, before: null };
}
if (op.op === "replace") {
const synth = /^\/~by-([^/]+)~\/([^/]+)\/(.+)$/u.exec(op.path);
if (synth) {
const [, field, enc, tail] = synth;
const val = decodeURIComponent(enc!);
const data = await fetchCollection(collection);
const row = (data as Record<string, unknown>[]).find(
(r) => String(r[field!]) === val,
);
if (!row) {return null;}
if (tail === "__merge__") {
const patch = op.value as Record<string, unknown>;
return { after: applyMergeToRow(row, patch), before: row };
}
const pathSegs = tail.split("/").filter(Boolean);
return { after: setValueAtPath(row, pathSegs, op.value), before: row };
}
// Direct array-index path: /idx or /idx/field/...
const parts = op.path.split("/").filter(Boolean);
if (parts.length > 0 && !isNaN(Number(parts[0]))) {
const idx = Number(parts[0]);
const data = await fetchCollection(collection);
const row = data[idx] as Record<string, unknown> | undefined;
if (!row) {return null;}
if (parts.length === 1) {
return { after: op.value as Record<string, unknown>, before: row };
}
const fieldPath = parts.slice(1);
return { after: setValueAtPath(row, fieldPath, op.value), before: row };
}
return { after: op.value as Record<string, unknown>, before: null };
}
return null;
}
// ---------------------------------------------------------------------------
// Draft item — op / time / discard in a slim bar; diff card always visible
// (same id=…, optional pretty title, field table, Show JSON as on /diff).
// ---------------------------------------------------------------------------
function DraftItem({ draft, onDiscard }: { draft: Draft; onDiscard: () => void }) {
const [diffLoading, setDiffLoading] = useState(true);
const [docPair, setDocPair] = useState<DocPair | null>(null);
const [diffError, setDiffError] = useState<string | null>(null);
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
let cancelled = false;
setDiffLoading(true);
setDiffError(null);
void computeDocPair(draftRef.current)
.then((p) => {
if (!cancelled) {setDocPair(p);}
})
.catch((err) => {
if (!cancelled) {
setDiffError(err instanceof Error ? err.message : String(err));
}
})
.finally(() => {
if (!cancelled) {setDiffLoading(false);}
});
return () => {
cancelled = true;
};
}, [draft.id]);
const { op, createdAt } = draft;
const opClass =
op.op === "add"
? "badge-op-add"
: op.op === "remove"
? "badge-op-remove"
: "badge-op-replace";
return (
<div className="draft-item">
<div className="draft-item-toolbar">
<span className={`draft-op-badge ${opClass}`}>{op.op}</span>
<span className="draft-item-time">{new Date(createdAt).toLocaleTimeString()}</span>
<span className="draft-item-toolbar-spacer" />
<button
className="draft-item-drop"
onClick={() => onDiscard()}
title="Discard this edit"
type="button"
>
×
</button>
</div>
<div className="draft-item-diff">
{diffLoading ? (
<div className="draft-diff-loading">Loading diff…</div>
) : diffError ? (
<div className="draft-diff-error">{diffError}</div>
) : docPair ? (
<SingleRowDocumentDiff
after={docPair.after}
before={docPair.before}
collectionName={draft.collection}
/>
) : (
<div className="draft-diff-loading">No diff available.</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Collection group.
// ---------------------------------------------------------------------------
interface DraftCollectionProps {
collection: string;
drafts: Draft[];
onDiscard: (id: string) => Promise<void>;
onDiscardCollection: () => Promise<void>;
}
function DraftCollection({
collection,
drafts,
onDiscard,
onDiscardCollection,
}: DraftCollectionProps) {
return (
<div className="draft-collection">
<div className="draft-collection-head">
<code className="draft-collection-name">{collection}</code>
<span className="draft-collection-count">
{drafts.length} {drafts.length === 1 ? "op" : "ops"}
</span>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => void onDiscardCollection()}
type="button"
>
Discard all
</button>
</div>
<div className="draft-collection-items">
{drafts.map((d) => (
<DraftItem draft={d} key={d.id} onDiscard={() => void onDiscard(d.id)} />
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page root.
// ---------------------------------------------------------------------------
export function Drafts() {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [error, setError] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const refresh = useCallback(async () => {
setDrafts(await listDrafts());
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function apply() {
setError(null);
setApplying(true);
try {
const transport = await getTransport();
if (!transport.writeCollection) {
throw new Error("Transport is read-only (not in localdev).");
}
const grouped = composeDrafts(drafts);
for (const [collection, patch] of grouped) {
const expanded = await expandPatch(transport, collection, patch);
await transport.writeCollection(collection, expanded);
}
await clearDrafts();
// Bust collection cache so the next expand shows fresh data.
bustAll();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setApplying(false);
}
}
const byCollection = new Map<string, Draft[]>();
for (const d of drafts) {
const bucket = byCollection.get(d.collection) ?? [];
bucket.push(d);
byCollection.set(d.collection, bucket);
}
return (
<div>
<div className="drafts-header">
<div>
<h2 className="page-title">
Drafts
{drafts.length > 0 ? (
<span className="drafts-count-badge">{drafts.length}</span>
) : null}
</h2>
<p className="page-subtitle">
Staged edits. Apply to write them to disk — seeds are re-sorted
automatically.
</p>
</div>
<div className="drafts-actions">
<button
className="btn btn-primary"
disabled={drafts.length === 0 || applying}
onClick={() => void apply()}
>
{applying ? "Applying…" : `Apply all (${drafts.length})`}
</button>
<button
className="btn btn-outline-danger"
disabled={drafts.length === 0}
onClick={async () => {
await clearDrafts();
bustAll();
await refresh();
}}
>
Discard all
</button>
</div>
</div>
{error ? <div className="alert alert-danger mono mb-3">{error}</div> : null}
{drafts.length === 0 ? (
<div className="drafts-empty">
<div className="drafts-empty-icon">✎</div>
<div className="drafts-empty-title">No staged edits</div>
<p>Stage some from Bulk edit or a row editor.</p>
</div>
) : (
<div className="drafts-collections">
{[...byCollection.entries()].map(([collection, group]) => (
<DraftCollection
collection={collection}
drafts={group}
key={collection}
onDiscard={async (id) => {
await removeDraft(id);
await refresh();
}}
onDiscardCollection={async () => {
await clearDrafts(collection);
bustCollection(collection);
await refresh();
}}
/>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Apply helpers
// ---------------------------------------------------------------------------
async function expandPatch(
transport: SeedsTransport,
collection: string,
patch: JsonPatch,
): Promise<JsonPatch> {
const synthetic = patch.some((op) => /^\/~by-[^/]+~\//u.test(op.path));
if (!synthetic) {return patch;}
const current = (await transport.getCollection(collection)) as Array<Record<string, unknown>>;
const out: JsonPatch = [];
for (const op of patch) {
const m = /^\/~by-([^/]+)~\/([^/]+)\/(.+)$/u.exec(op.path);
if (!m) {
out.push(op);
continue;
}
const [, field, encValue, tail] = m;
const value = decodeURIComponent(encValue!);
const idx = current.findIndex((r) => String(r[field!]) === value);
if (idx < 0) {
throw new Error(`draft refers to ${field}=${value} but no such row in ${collection}`);
}
if (tail === "__merge__" && op.op === "replace") {
const patchObj = op.value as Record<string, unknown>;
const row = current[idx]!;
out.push(...mergeToPatchOps(row, idx, patchObj));
} else {
out.push({
...op,
path: `/${idx}/${tail}`,
} as JsonPatchOp);
}
}
return out;
}
@@ -0,0 +1,119 @@
import { CommitSparkline } from "#components/CommitSparkline";
import { useIngest } from "#lib/ingest/IngestProvider";
import { getSqlite } from "#lib/sqlite/client";
import { getTransport } from "#lib/transport/transport";
import { useQuery } from "react-query";
import { Link } from "react-router-dom";
// SQLite table name -> original collection file name. The ingest code does
// `name.replace(/\.json$/, "").replace(/-/g, "_")`; none of the existing
// seeds files contain a literal underscore, so this reverse is lossless.
function tableToCollection(table: string): string {
return `${table.replace(/_/gu, "-")}.json`;
}
export function Overview() {
const { ready } = useIngest();
const counts = useQuery("table-counts", () => getSqlite().tableCounts(), {
enabled: ready,
staleTime: 30_000,
});
// We fetch a moderately deep commit history once and slice it two ways:
// the first 10 for the "recent commits" list, and the whole range for
// the sparkline. listCommits returns at most one page (~100 commits)
// which is plenty for a 90-day pulse.
const commitHistory = useQuery(
"commit-history",
async () => {
const t = await getTransport();
return (await t.listCommits({})).commits;
},
{ staleTime: 60_000 },
);
const recentCommits = (commitHistory.data ?? []).slice(0, 10);
const totalRows = (counts.data ?? []).reduce((s, c) => s + c.rows, 0);
return (
<div>
<h2 className="page-title">Overview</h2>
<p className="page-subtitle">
Query the seeds database in-browser, walk through git history, and inspect changes.
</p>
<div className="stat-grid mb-4">
<StatCard label="Tables" value={counts.data ? counts.data.length : "…"} />
<StatCard
label="Total rows"
value={counts.data ? totalRows.toLocaleString() : "…"}
/>
<StatCard label="Recent commits" value={commitHistory.data?.length ?? "…"} />
</div>
{commitHistory.data ? (
<section className="mb-4">
<CommitSparkline commits={commitHistory.data} />
</section>
) : null}
<section className="mb-4">
<h4 className="mb-2">Collections</h4>
<div className="spacer-y">
{counts.data
? counts.data.map((c) => {
const collection = tableToCollection(c.name);
return (
<Link
className="row-link"
key={c.name}
to={`/c/${encodeURIComponent(collection)}`}
>
<span className="subject mono">{collection}</span>
<span className="row-count">
{c.rows.toLocaleString()} rows
</span>
</Link>
);
})
: Array.from({ length: 8 }, (_, i) => <SkeletonRow key={i} />)}
</div>
</section>
<section>
<h4 className="mb-2">Recent commits</h4>
<div className="spacer-y">
{commitHistory.data
? recentCommits.map((c) => (
<Link
className="row-link"
key={c.sha}
to={`/diff?base=${c.parents[0]?.sha ?? ""}&head=${c.sha}`}
>
<code className="sha">{c.sha.slice(0, 7)}</code>
<span className="subject">{c.message.split("\n")[0]}</span>
<span className="meta">
{c.author.name} ·{" "}
{new Date(c.author.date).toLocaleDateString()}
</span>
</Link>
))
: Array.from({ length: 5 }, (_, i) => <SkeletonRow key={i} />)}
</div>
</section>
</div>
);
}
function SkeletonRow() {
return <div aria-hidden="true" className="skeleton-row" />;
}
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="stat-card">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import type { QueryResult } from "#lib/sqlite/types";
import { ResultTable } from "#components/ResultTable";
import { SchemaPanel } from "#components/SchemaPanel";
import { SqlEditor } from "#components/SqlEditor";
import { SqliteWorkspaceGate } from "#components/SqliteWorkspaceGate";
import { useIngest } from "#lib/ingest/IngestProvider";
import { getSqlite } from "#lib/sqlite/client";
import { useEffect, useRef, useState } from "react";
const SAMPLE = `SELECT
level, COUNT(*) AS count
FROM
charts_iidx_sp
GROUP BY
level
ORDER BY
count DESC
;`;
export function Query() {
const { ready: sqliteReady } = useIngest();
const [sql, setSql] = useState<string>(
() => localStorage.getItem("seeds-webui:last-sql") ?? SAMPLE,
);
const sqlRef = useRef(sql);
sqlRef.current = sql;
const [schema, setSchema] = useState<Record<string, string[]> | null>(null);
const [showSchema, setShowSchema] = useState(true);
const [result, setResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
useEffect(() => {
if (!sqliteReady) {
return;
}
let cancelled = false;
void getSqlite()
.getSchema()
.then((s) => {
if (!cancelled) {
setSchema(s);
}
})
.catch(() => {
if (!cancelled) {
setSchema({});
}
});
return () => {
cancelled = true;
};
}, [sqliteReady]);
async function run() {
const text = sqlRef.current;
setError(null);
setRunning(true);
try {
const r = await getSqlite().query(text);
setResult(r);
localStorage.setItem("seeds-webui:last-sql", text);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunning(false);
}
}
return (
<div className="sql-console">
<h2 className="page-title">Query</h2>
<p className="page-subtitle">
Runs against the in-browser SQLite mirror of the seeds on disk. Read-only. Use
Ctrl+Space in the editor for completions (tables, columns, keywords).
</p>
<SqliteWorkspaceGate>
<div className="sql-editor-row mb-2">
<div className="sql-editor-main">
<SqlEditor
className="mb-0"
onChange={(next) => {
sqlRef.current = next;
setSql(next);
}}
onRun={() => {
void run();
}}
schema={schema}
value={sql}
/>
</div>
{showSchema ? (
<div className="schema-sidebar">
<SchemaPanel schema={schema} />
</div>
) : null}
</div>
<div className="d-flex flex-wrap gap-2 mb-3">
<button className="btn btn-primary" disabled={running} onClick={() => void run()}>
{running ? "Running…" : "Run (Ctrl-Enter)"}
</button>
<button
className="btn btn-outline-secondary"
onClick={() => {
setShowSchema((v) => !v);
}}
type="button"
>
{showSchema ? "Hide schema" : "Show schema"}
</button>
{result ? (
<span className="text-muted align-self-center">
{result.rows.length} rows · {result.elapsedMs.toFixed(1)} ms
</span>
) : null}
</div>
{error ? (
<div className="alert alert-danger mono">{error}</div>
) : result ? (
<ResultTable result={result} />
) : null}
</SqliteWorkspaceGate>
</div>
);
}
@@ -0,0 +1,41 @@
import { RunOutput } from "#components/RunOutput";
import { getTransport , type RunEvent } from "#lib/transport/transport";
import { useState } from "react";
export function Validate() {
const [events, setEvents] = useState<RunEvent[]>([]);
const [running, setRunning] = useState(false);
async function run() {
setEvents([]);
setRunning(true);
try {
const t = await getTransport();
if (!t.runTests) {throw new Error("Read-only transport");}
for await (const ev of t.runTests()) {
setEvents((prev) => [...prev, ev]);
}
} catch (err) {
setEvents((prev) => [
...prev,
{ data: `\n[client error] ${(err as Error).message}\n`, kind: "stderr" },
]);
} finally {
setRunning(false);
}
}
return (
<div>
<h2 className="page-title">Validate</h2>
<p className="page-subtitle">
Runs the <code>seeds-scripts</code> test suite (schemas, references, IDs, …) and
streams the output.
</p>
<button className="btn btn-primary mb-3" disabled={running} onClick={() => void run()}>
{running ? "Running…" : "Run seeds-scripts tests"}
</button>
{events.length > 0 ? <RunOutput events={events} /> : null}
</div>
);
}
@@ -0,0 +1,37 @@
// Tachi brand pink — matches the .st1 fill in logo.svg.
$tachi-pink: #e61c6e;
// Bootstrap overrides (must come before `@import "bootstrap-scss/bootstrap"`).
$primary: $tachi-pink;
$font-family-sans-serif:
"Poppins",
system-ui,
-apple-system,
"Helvetica Neue",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji";
$font-family-monospace: "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
// Match tachi-client's type scale.
$font-size-root: 13px;
$font-size-base: 1rem;
$border-radius: 0.5rem;
$border-radius-sm: 0.25rem;
$border-radius-lg: 0.75rem;
$font-weight-normal: 400;
$font-weight-semibold: 500;
$font-weight-bold: 600;
$min-contrast-ratio: 3;
// Slightly warmer/cooler grays on each theme — matches the vibe of tachi-client.
$body-bg-dark: #131313;
$body-tertiary-bg-dark: #1a1a1a;
$body-secondary-bg-dark: #242424;
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "Preserve",
"moduleResolution": "bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022", "WebWorker"],
"jsx": "react-jsx",
"allowJs": true,
"types": ["vite/client"],
"paths": {
"#*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"types": []
},
"include": ["vite.config.ts", "dev/**/*.ts", "scripts/**/*.ts"]
}
+152
View File
@@ -0,0 +1,152 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import { seedsDevPlugin } from "./dev/vite-plugin-seeds-dev";
// seeds-webui is a pure static SPA in production.
// In `vite dev` the seedsDevPlugin mounts /__seeds/* endpoints against the
// local repo's db/seeds/ so the UI can edit, query git history, and run
// the seeds-scripts tests. These endpoints are NOT present in the
// production build — all references to them are gated behind
// import.meta.env.VITE_SEEDS_EDIT_MODE and tree-shaken out.
const REPO_ROOT = path.resolve(__dirname, "../..");
/** Hoisted path for a workspace @codemirror/* package (single instance — avoids "Unrecognized extension value" from nested node_modules). */
const cmPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@codemirror", name);
const codemirrorSingleInstanceAliases: {
find: string;
replacement: string;
}[] = [
"autocomplete",
"commands",
"lang-sql",
"language",
"lint",
"search",
"state",
"view",
].map((name) => ({ find: `@codemirror/${name}`, replacement: cmPackage(name) }));
const lezerPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@lezer", name);
/** One copy of @lezer/* (nested under @codemirror/* breaks NodeProp + highlight rules, → undefined rule.tags, TreeHighlighter crash). */
const lezerSingleInstanceAliases: {
find: string;
replacement: string;
}[] = ["common", "highlight", "lr"].map((name) => ({
find: `@lezer/${name}`,
replacement: lezerPackage(name),
}));
export default defineConfig(({ command }) => ({
// Edit mode is only on for `vite dev` — `vite build` and `vite preview`
// produce a read-only bundle suitable for hosting at seeds.tachi.ac.
define: {
"import.meta.env.VITE_SEEDS_EDIT_MODE": JSON.stringify(command === "serve"),
"import.meta.env.VITE_SEEDS_REPO": JSON.stringify(
process.env.VITE_SEEDS_REPO ?? "zkldi/tachi",
),
"import.meta.env.VITE_SEEDS_BRANCH": JSON.stringify(
process.env.VITE_SEEDS_BRANCH ?? "main",
),
},
build: {
outDir: process.env.BUILD_OUT_DIR || "build",
sourcemap: true,
target: "es2022",
},
worker: {
format: "es",
},
optimizeDeps: {
// sqlite-wasm ships pre-compiled wasm + a worker-friendly loader. Stop Vite from
// trying to pre-bundle it, which breaks its own inline worker.
exclude: ["@sqlite.org/sqlite-wasm"],
},
plugins: [
react(),
...(command === "serve"
? [seedsDevPlugin({ repoRoot: REPO_ROOT })]
: [
// In prod builds, short-circuit modules that are only reachable
// when EDIT_MODE is on. `editRoutes` hosts the edit-only pages
// and `schemas` pulls in the whole tachi-common game config tree
// for zod introspection — we don't want either of those shipping
// to seeds.tachi.ac.
{
name: "seeds-webui:strip-edit-routes",
enforce: "pre" as const,
resolveId(source: string) {
if (
source.endsWith("/app/editRoutes") ||
source.endsWith("/app/editRoutes.ts")
) {
return "\0virtual:empty-edit-routes";
}
if (
source.endsWith("#lib/edits/schemas") ||
source.endsWith("/lib/edits/schemas") ||
source.endsWith("/lib/edits/schemas.ts")
) {
return "\0virtual:empty-schemas";
}
return null;
},
load(id: string) {
if (id === "\0virtual:empty-edit-routes") {
return "export const editRoutes = [];";
}
if (id === "\0virtual:empty-schemas") {
return "export function schemaForCollection() { return null; }";
}
return null;
},
},
]),
],
resolve: {
dedupe: [
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
],
alias: [
...lezerSingleInstanceAliases,
...codemirrorSingleInstanceAliases,
{
find: /^tachi-common(.*)$/u,
replacement: path.resolve(__dirname, "../common/src", "$1"),
},
{
find: /^#(.*)$/u,
replacement: path.resolve(__dirname, "src/$1"),
},
],
},
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ["color-functions", "global-builtin", "if-function", "import"],
},
},
},
server: {
host: true,
open: false,
port: 3100,
},
preview: {
open: false,
port: 3100,
},
}));
@@ -25,7 +25,7 @@ export function CreateScoreCalcData<TGame extends V3Game>(
chart as any,
) as MongoDerivedMetrics[TGame];
// Per-GPT `scoreCalcs` take game-specific score/derived types; at runtime inputs match `gptString`.
// Per-GPT `scoreCalcs` take game-specific score/derived types; at runtime inputs match `game`.
const scoreCalcs = impl.scoreCalcs as unknown as (
scoreData: ScoreData<TGame>,
derivedData: MongoDerivedMetrics[TGame],
@@ -59,7 +59,7 @@ export async function rederiveScoresForChart(chartId: string, log: KtLogger): Pr
for (const row of rows) {
const score = ToScoreDocument(row as ScoreDocumentJoinRow);
// GPT-specific union types aren't callable with the generic GPTString,
// game-specific union types aren't callable with the generic game,
// so we cast through `any` the same way CreateScoreCalcData does.
const derivedData = (impl.scoreDeriver as any)(score.scoreData, chart);
+1 -3
View File
@@ -29,7 +29,6 @@ import {
type GoalDocument,
type GoalSubscriptionDocument,
type integer,
LEGACY_GameToGPTString,
type PBScoreDocument,
type QuestDocument,
type QuestSubscriptionDocument,
@@ -72,13 +71,12 @@ export async function EvaluateGoalForUser(
): Promise<EvaluatedGoalReturn | null> {
const chartIDs = await ResolveGoalCharts(goal);
const v3Game = goal.game;
const gptString = LEGACY_GameToGPTString(v3Game);
const gameConfig = GetGameConfig(v3Game);
const scoreConf = GetScoreMetricConf(gameConfig, goal.criteria.key);
if (!scoreConf) {
throw new Error(
`Invalid goal.criteria.key, got '${goal.criteria.key}', but no config exists for this metric for ${gptString}.`,
`Invalid goal.criteria.key, got '${goal.criteria.key}', but no config exists for this metric for ${v3Game}.`,
);
}