v26.10.1 正式版
修复部分问题,优化性能,进一步推进类型系统
This commit is contained in:
1 parent
48eec484b7
commit
8ce4239452
89 files changed
+5808
-884
No files matched your search
@@ -20,6 +20,7 @@ library
|
||||
, aeson
|
||||
, bytestring
|
||||
, chusql-core-model
|
||||
, chusql-core-engine
|
||||
, chusql-interface
|
||||
, containers
|
||||
, directory
|
||||
|
||||
@@ -21,7 +21,9 @@ module ChuSQL.Web.API (
|
||||
) where
|
||||
|
||||
import ChuSQL.Core.Model (Row, Value (..))
|
||||
import ChuSQL.Core.Protocol (Account (..), QueryResult (..), SchemaColumn (..), TableInfo (..))
|
||||
import ChuSQL.Core.Engine.Syntax.AST (Statement (BeginTransaction, CommitTransaction, RollbackTransaction))
|
||||
import ChuSQL.Core.Engine.Syntax.Parser (parseStatement)
|
||||
import ChuSQL.Core.Protocol (Account (..), QueryResult (..), SchemaColumn (..), TableInfo (..), valueToJSON)
|
||||
import ChuSQL.Interface.AccountTable (accountColumns, accountTableName, isAccountTable, passwordColumn, systemTableInfo)
|
||||
import ChuSQL.Interface.Actions (
|
||||
ColumnSpec (..),
|
||||
@@ -108,16 +110,19 @@ import ChuSQL.Web.UISettings (
|
||||
writeUISettings,
|
||||
)
|
||||
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, withMVar)
|
||||
import Control.Concurrent (forkIO, threadDelay)
|
||||
import Control.Exception (IOException, try)
|
||||
import Control.Monad (forever, when)
|
||||
import Data.Aeson (FromJSON (..), eitherDecode, object, withObject, (.:), (.=))
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
||||
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
|
||||
import Data.List (nub, sortBy)
|
||||
import qualified Data.Map.Strict as Map
|
||||
import qualified Data.Set as Set
|
||||
import Data.Maybe (catMaybes, fromMaybe)
|
||||
import Data.Scientific (fromFloatDigits)
|
||||
import Data.Text (Text)
|
||||
@@ -176,6 +181,7 @@ data AppEnv = AppEnv
|
||||
, aeUISettingsFile :: FilePath
|
||||
, aeEffective :: Map.Map Text Text
|
||||
, aeLog :: Text -> IO ()
|
||||
, aeReaperStarted :: IORef Bool
|
||||
}
|
||||
|
||||
-- | 一条网页会话:TCP 连接、登录状态与账号名
|
||||
@@ -184,6 +190,12 @@ data WebSession = WebSession
|
||||
, wsSession :: Session
|
||||
, wsUser :: Text
|
||||
, wsAdmin :: Bool
|
||||
, wsEditor :: Session
|
||||
, wsEditorClient :: Client
|
||||
, wsEdit :: IORef (Maybe (Text, Text))
|
||||
, wsEditReceipt :: IORef (Maybe (Text, A.Value, A.Value))
|
||||
, wsEditEnded :: IORef (Set.Set Text)
|
||||
, wsConsole :: IORef (Maybe (Text, Text))
|
||||
}
|
||||
|
||||
-- | 会话 Cookie 名
|
||||
@@ -201,6 +213,7 @@ newAppEnvAt :: FilePath -> Text -> Int -> RateLimiter -> FilePath -> IO AppEnv
|
||||
newAppEnvAt settingsFile host port limiter staticDir = do
|
||||
sessions <- newPayloadStore getCurrentTime defaultSessionPolicy
|
||||
liveRef <- newIORef defaultLive
|
||||
reaperStarted <- newIORef False
|
||||
uiSettingsFile <- resolveUISettingsFileSafe
|
||||
pure
|
||||
AppEnv
|
||||
@@ -215,6 +228,7 @@ newAppEnvAt settingsFile host port limiter staticDir = do
|
||||
, aeUISettingsFile = uiSettingsFile
|
||||
, aeEffective = Map.empty
|
||||
, aeLog = const (pure ())
|
||||
, aeReaperStarted = reaperStarted
|
||||
}
|
||||
|
||||
-- | 系统库:账号表住在这里,只有管理员能进
|
||||
@@ -256,6 +270,12 @@ liveBodyLimit env = lvBodyLimit <$> readLive env
|
||||
-- | 组装 WAI 应用:路由外套四层中间件
|
||||
webApp :: AppEnv -> IO Application
|
||||
webApp env = do
|
||||
started <- atomicModifyIORef' (aeReaperStarted env) (\old -> (True, old))
|
||||
when (not started) $ do
|
||||
_ <- forkIO $ forever $ do
|
||||
threadDelay 60000000
|
||||
sweepWebSessions env
|
||||
pure ()
|
||||
inner <- scottyApp (routes env)
|
||||
locks <- newMVar Map.empty
|
||||
pure (securityHeaders (sameOriginOnly (bodyLimitDynamic (liveBodyLimit env) (serializeSessions locks inner))))
|
||||
@@ -318,6 +338,10 @@ routes env = do
|
||||
post "/api/roles/:name/members" (withDatabase env addRoleMemberH)
|
||||
delete "/api/roles/:name/members/:user" (withDatabase env removeRoleMemberH)
|
||||
post "/api/query" (withDatabase env queryH)
|
||||
post "/api/query/rollback" (rollbackConsoleH env)
|
||||
post "/api/edit/begin" (beginEditH env)
|
||||
post "/api/edit/commit" (commitEditH env)
|
||||
post "/api/edit/rollback" (rollbackEditH env)
|
||||
notFound (notFoundH env)
|
||||
|
||||
-- | 先认会话,再按请求头切本会话的库
|
||||
@@ -331,7 +355,18 @@ withDatabase env action = do
|
||||
if chosen == systemDatabase && not (wsAdmin ws)
|
||||
then reply403 "the system database is only available to the administrator"
|
||||
else do
|
||||
ready <- liftIO (if T.null chosen then pure (Right ()) else switchDatabase (wsSession ws) chosen)
|
||||
editing <- header "X-ChuSQL-Edit"
|
||||
req <- request
|
||||
let snapshotRead = editing /= Nothing && requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"]
|
||||
console <- liftIO (readIORef (wsConsole ws))
|
||||
caller <- consoleOwner
|
||||
case console of
|
||||
Just (owner, database) | not snapshotRead &&
|
||||
(pathInfo req /= ["api", "query"] || caller /= owner || (not (T.null chosen) && chosen /= database)) ->
|
||||
apiError "conflict" "another SQL console owns this connection transaction; commit or rollback there first"
|
||||
_ -> pure ()
|
||||
current <- liftIO (sessionDatabase (wsSession ws))
|
||||
ready <- liftIO (if T.null chosen || chosen == current || snapshotRead then pure (Right ()) else switchDatabase (wsSession ws) chosen)
|
||||
case ready of
|
||||
Left message -> serverError message
|
||||
Right () -> action env
|
||||
@@ -347,11 +382,31 @@ requireDatabaseName ws = do
|
||||
-- | 取本请求的会话,没有令牌就 401
|
||||
requireSession :: AppEnv -> ActionM WebSession
|
||||
requireSession env = do
|
||||
liftIO (sweepWebSessions env)
|
||||
token <- requestSessionToken >>= maybe (apiError "unauthorized" "sign in first") pure
|
||||
found <- liftIO (lookupPayload (aeSessions env) token)
|
||||
case found of
|
||||
Nothing -> apiError "unauthorized" "sign in first"
|
||||
Just (_, ws) -> pure ws
|
||||
Just (_, ws) -> do
|
||||
editHeader <- fmap TL.toStrict <$> header "X-ChuSQL-Edit"
|
||||
req <- request
|
||||
case editHeader of
|
||||
Just editId | requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"] -> do
|
||||
active <- liftIO (readIORef (wsEdit ws))
|
||||
database <- maybe "" (T.toLower . TL.toStrict) <$> header "X-ChuSQL-Database"
|
||||
case active of
|
||||
Just (owner, name) | owner == editId && name == database ->
|
||||
pure ws {wsSession = wsEditor ws, wsClient = wsEditorClient ws}
|
||||
_ -> apiError "conflict" "the editing snapshot is no longer available; rollback and reload"
|
||||
_ -> pure ws
|
||||
|
||||
-- | 关闭网页会话的两个服务端连接。
|
||||
closeWebSession :: WebSession -> IO ()
|
||||
closeWebSession ws = endEdit ws >> writeIORef (wsConsole ws) Nothing >> closeClient (wsEditorClient ws) >> closeClient (wsClient ws)
|
||||
|
||||
-- | 清理过期会话与未提交快照。
|
||||
sweepWebSessions :: AppEnv -> IO ()
|
||||
sweepWebSessions env = sweepPayloads (aeSessions env) >>= mapM_ closeWebSession
|
||||
|
||||
-- | 管理员专属操作
|
||||
requireAdminSession :: AppEnv -> ActionM WebSession
|
||||
@@ -388,7 +443,7 @@ createDatabaseH env = do
|
||||
-- | 删除数据库;系统库 system 一律拒绝。
|
||||
dropDatabaseH :: AppEnv -> ActionM ()
|
||||
dropDatabaseH env = do
|
||||
ws <- requireAdminSession env
|
||||
ws <- requireSession env
|
||||
name <- pathParam "name"
|
||||
if reservedDatabase name
|
||||
then reply400 "bad_request" "the system database cannot be dropped"
|
||||
@@ -443,8 +498,7 @@ healthH = json (object ["status" .= ("ok" :: Text)])
|
||||
-- | 探活:回进程与存储状态,顺带清理过期会话
|
||||
statusH :: AppEnv -> ActionM ()
|
||||
statusH env = do
|
||||
expired <- liftIO (sweepPayloads (aeSessions env))
|
||||
liftIO (mapM_ (closeClient . wsClient) expired)
|
||||
liftIO (sweepWebSessions env)
|
||||
opened <- liftIO (connectClient (aeServerHost env) (aeServerPort env))
|
||||
case opened of
|
||||
Left message -> json (object ["status" .= ("ok" :: Text), "storage" .= ("down" :: Text), "detail" .= message])
|
||||
@@ -504,7 +558,21 @@ openSession env user password = do
|
||||
else serverError message
|
||||
Right () -> do
|
||||
admin <- liftIO (sessionIsAdmin session)
|
||||
token <- liftIO (createPayloadSession (aeSessions env) user (WebSession client session user admin))
|
||||
openedEditor <- liftIO (connectClient (aeServerHost env) (aeServerPort env))
|
||||
editorClient <- case openedEditor of
|
||||
Left message -> liftIO (closeClient client) >> serverError message
|
||||
Right connected -> pure connected
|
||||
editor <- liftIO (newSession editorClient)
|
||||
editorSigned <- liftIO (authenticateSession editor user password)
|
||||
case editorSigned of
|
||||
Left message -> liftIO (closeClient editorClient >> closeClient client) >> serverError message
|
||||
Right () -> pure ()
|
||||
edit <- liftIO (newIORef Nothing)
|
||||
receipt <- liftIO (newIORef Nothing)
|
||||
ended <- liftIO (newIORef Set.empty)
|
||||
console <- liftIO (newIORef Nothing)
|
||||
liftIO (sweepWebSessions env)
|
||||
token <- liftIO (createPayloadSession (aeSessions env) user (WebSession client session user admin editor editorClient edit receipt ended console))
|
||||
liftIO (rateLimitClear (aeLimiter env) user)
|
||||
setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env token (Just (lvSessionMax live))))
|
||||
liftIO (aeLog env ("sign-in ok user=" <> user))
|
||||
@@ -513,6 +581,7 @@ openSession env user password = do
|
||||
-- | 退出:收掉会话与连接,清除 Cookie
|
||||
logoutH :: AppEnv -> ActionM ()
|
||||
logoutH env = do
|
||||
liftIO (sweepWebSessions env)
|
||||
token <- requestSessionToken
|
||||
case token of
|
||||
Nothing -> pure ()
|
||||
@@ -520,7 +589,7 @@ logoutH env = do
|
||||
gone <- liftIO (dropPayload (aeSessions env) t)
|
||||
case gone of
|
||||
Nothing -> pure ()
|
||||
Just ws -> liftIO (closeClient (wsClient ws))
|
||||
Just ws -> liftIO (closeWebSession ws)
|
||||
setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env "" (Just 0)))
|
||||
status status204
|
||||
|
||||
@@ -1036,7 +1105,6 @@ createTableH :: AppEnv -> ActionM ()
|
||||
createTableH env = do
|
||||
ws <- requireSession env
|
||||
_ <- requireDatabaseName ws
|
||||
adminOnly ws
|
||||
withJsonObject env $ \o -> case decodeCreateTable o of
|
||||
Left err -> reply400 "bad_request" err
|
||||
Right spec -> case createTableSql spec of
|
||||
@@ -1048,7 +1116,6 @@ dropTableH :: AppEnv -> ActionM ()
|
||||
dropTableH env = do
|
||||
ws <- requireSession env
|
||||
_ <- requireDatabaseName ws
|
||||
adminOnly ws
|
||||
name <- pathParam "t"
|
||||
account <- accountRoute ws name
|
||||
if account
|
||||
@@ -1117,7 +1184,6 @@ createIndexH :: AppEnv -> ActionM ()
|
||||
createIndexH env = do
|
||||
ws <- requireSession env
|
||||
_ <- requireDatabaseName ws
|
||||
adminOnly ws
|
||||
name <- pathParam "t"
|
||||
account <- accountRoute ws name
|
||||
if account
|
||||
@@ -1137,7 +1203,6 @@ dropIndexH :: AppEnv -> ActionM ()
|
||||
dropIndexH env = do
|
||||
ws <- requireSession env
|
||||
_ <- requireDatabaseName ws
|
||||
adminOnly ws
|
||||
name <- pathParam "t"
|
||||
column <- pathParam "col"
|
||||
account <- accountRoute ws name
|
||||
@@ -1156,7 +1221,6 @@ dropColumnH :: AppEnv -> ActionM ()
|
||||
dropColumnH env = do
|
||||
ws <- requireSession env
|
||||
_ <- requireDatabaseName ws
|
||||
adminOnly ws
|
||||
name <- pathParam "t"
|
||||
column <- pathParam "col"
|
||||
account <- accountRoute ws name
|
||||
@@ -1233,7 +1297,7 @@ dropRoleH env = do
|
||||
-- | 给角色加权限
|
||||
grantRoleH :: AppEnv -> ActionM ()
|
||||
grantRoleH env = do
|
||||
ws <- requireAdminSession env
|
||||
ws <- requireSession env
|
||||
role <- pathParam "name"
|
||||
withJsonObject env $ \o -> case grantFields o of
|
||||
Left err -> reply400 "bad_request" err
|
||||
@@ -1243,7 +1307,7 @@ grantRoleH env = do
|
||||
-- | 收角色的权限
|
||||
revokeRoleH :: AppEnv -> ActionM ()
|
||||
revokeRoleH env = do
|
||||
ws <- requireAdminSession env
|
||||
ws <- requireSession env
|
||||
role <- pathParam "name"
|
||||
withJsonObject env $ \o -> case grantFields o of
|
||||
Left err -> reply400 "bad_request" err
|
||||
@@ -1418,7 +1482,219 @@ columnTypeText cols name = case [c | c <- cols, scName c == T.unpack name] of
|
||||
(c : _) -> T.pack (scType c)
|
||||
[] -> ""
|
||||
|
||||
-- | 查询接口:校验请求体后执行 SQL
|
||||
-- | 校验编辑事务的页面与数据库标识。
|
||||
editIdentity :: A.Object -> Either Text (Text, Text)
|
||||
editIdentity o = do
|
||||
owner <- textField "id" o
|
||||
database <- textField "database" o
|
||||
if T.null owner || T.length owner > 128 || not (isPlainIdentifier database)
|
||||
then Left "invalid edit identity or database"
|
||||
else Right (owner, T.toLower database)
|
||||
|
||||
-- | 开始或确认同一页面的编辑快照。
|
||||
beginEditH :: AppEnv -> ActionM ()
|
||||
beginEditH env = do
|
||||
ws <- requireSession env
|
||||
withJsonObject env $ \o -> case editIdentity o of
|
||||
Left message -> reply400 "bad_request" message
|
||||
Right identity@(owner, database) -> do
|
||||
when (database == systemDatabase) (apiError "bad_request" "system identities cannot be edited in a data transaction")
|
||||
active <- liftIO (readIORef (wsEdit ws))
|
||||
ended <- liftIO (readIORef (wsEditEnded ws))
|
||||
case active of
|
||||
Just existing | existing == identity -> json (object ["state" .= ("active" :: Text), "id" .= owner])
|
||||
Just _ -> apiError "conflict" "another page owns the editing transaction"
|
||||
Nothing | Set.member owner ended -> apiError "conflict" "this editing transaction has already ended"
|
||||
Nothing -> do
|
||||
switched <- liftIO (switchDatabase (wsEditor ws) database)
|
||||
either serverError pure switched
|
||||
begun <- liftIO (runStatement (wsEditor ws) "BEGIN")
|
||||
either serverError (const (pure ())) begun
|
||||
saved <- liftIO (runStatement (wsEditor ws) "SAVEPOINT web_edit_base")
|
||||
case saved of
|
||||
Left message -> do
|
||||
liftIO (closeClient (wsEditorClient ws))
|
||||
serverError message
|
||||
Right _ -> do
|
||||
liftIO (writeIORef (wsEdit ws) (Just identity))
|
||||
json (object ["state" .= ("active" :: Text), "id" .= owner])
|
||||
|
||||
-- | 确认当前页面仍持有编辑事务。
|
||||
requireEdit :: WebSession -> (Text, Text) -> ActionM ()
|
||||
requireEdit ws identity = do
|
||||
active <- liftIO (readIORef (wsEdit ws))
|
||||
when (active /= Just identity) (apiError "conflict" "editing transaction is stale; rollback and reload")
|
||||
|
||||
-- | 结束事务并禁止旧页面标识重新使用。
|
||||
endEdit :: WebSession -> IO ()
|
||||
endEdit ws = do
|
||||
active <- readIORef (wsEdit ws)
|
||||
case active of
|
||||
Nothing -> pure ()
|
||||
Just (owner, _) -> atomicModifyIORef' (wsEditEnded ws) (\ended -> (Set.insert owner ended, ()))
|
||||
writeIORef (wsEdit ws) Nothing
|
||||
|
||||
-- | 按表结构生成一条编辑语句。
|
||||
editStatement :: [TableInfo] -> A.Value -> Either Text Text
|
||||
editStatement infos (A.Object o) = do
|
||||
name <- textField "table" o
|
||||
operation <- textField "op" o
|
||||
when (isAccountTable name) (Left "system identities cannot be edited in a data transaction")
|
||||
info <- maybe (Left ("table not found: " <> name)) Right (findTable name infos)
|
||||
sql <- case operation of
|
||||
"insert" -> do
|
||||
row <- valuesField o >>= readRowValues False (tiColumns info)
|
||||
either (Left . T.pack) Right (insertRowSql name row)
|
||||
"update" -> do
|
||||
key <- editRowKey o
|
||||
values <- case KM.lookup "set" o of
|
||||
Just (A.Object assigns) -> Right assigns
|
||||
_ -> Left "update requires a set object"
|
||||
row <- readRowValues False (tiColumns info) values
|
||||
either (Left . T.pack) Right (updateRowSql name key row)
|
||||
"delete" -> do
|
||||
key <- editRowKey o
|
||||
either (Left . T.pack) Right (deleteRowSql name key)
|
||||
_ -> Left "unknown edit operation"
|
||||
pure (T.pack sql)
|
||||
editStatement _ _ = Left "each change must be an object"
|
||||
|
||||
-- | 读取行编辑的整数主键。
|
||||
editRowKey :: A.Object -> Either Text Int
|
||||
editRowKey o = case KM.lookup "pk" o of
|
||||
Just (A.String rawKey) -> maybe (Left "invalid row id") Right (readMaybe (T.unpack rawKey))
|
||||
Just rawKey -> case A.fromJSON rawKey of
|
||||
A.Success key -> Right key
|
||||
A.Error _ -> Left "invalid row id"
|
||||
Nothing -> Left "missing row id"
|
||||
|
||||
-- | 顺序执行编辑集并定位首个错误。
|
||||
runEditStatements :: Session -> Int -> [Text] -> IO (Either (Int, Text) ())
|
||||
runEditStatements _ _ [] = pure (Right ())
|
||||
runEditStatements session index (sql : rest) = do
|
||||
result <- runStatement session sql
|
||||
case result of
|
||||
Left message -> pure (Left (index, message))
|
||||
Right _ -> runEditStatements session (index + 1) rest
|
||||
|
||||
-- | 返回明确的事务失败状态。
|
||||
editFailure :: Text -> Text -> Maybe Int -> Bool -> ActionM ()
|
||||
editFailure stateName message index reload = do
|
||||
let code = errorCodeOf message
|
||||
status (if code == "unauthorized" then status401 else if reload || code == "serialization_failure" then status409 else statusOf code)
|
||||
json (object ["ok" .= False, "applied" .= (0 :: Int), "state" .= stateName,
|
||||
"reloadRequired" .= reload, "error" .= code, "message" .= message,
|
||||
"errors" .= [object ["index" .= index, "message" .= message]]])
|
||||
|
||||
-- | 回退提交失败的暂存语句并保留快照。
|
||||
resetEditAfterFailure :: WebSession -> Maybe Int -> Text -> ActionM ()
|
||||
resetEditAfterFailure ws index message = do
|
||||
reset <- liftIO (runStatement (wsEditor ws) "ROLLBACK TO SAVEPOINT web_edit_base")
|
||||
case reset of
|
||||
Right _ -> editFailure "active" message index False
|
||||
Left resetError -> do
|
||||
liftIO (endEdit ws >> closeClient (wsEditorClient ws))
|
||||
editFailure "lost" (message <> "; rollback failed: " <> resetError) index True
|
||||
|
||||
-- | 原子提交跨表编辑集并缓存成功回执。
|
||||
commitEditH :: AppEnv -> ActionM ()
|
||||
commitEditH env = do
|
||||
ws <- requireSession env
|
||||
withJsonObject env $ \o -> case (editIdentity o, KM.lookup "changes" o) of
|
||||
(Right identity@(owner, _), Just (A.Array entries)) -> do
|
||||
receipt <- liftIO (readIORef (wsEditReceipt ws))
|
||||
case receipt of
|
||||
Just (old, submitted, result) | old == owner && submitted == A.Object o -> json result
|
||||
Just (old, _, _) | old == owner -> apiError "conflict" "transaction already committed with a different edit set"
|
||||
_ -> do
|
||||
requireEdit ws identity
|
||||
found <- liftIO (catalog (wsEditor ws))
|
||||
infos <- case found of
|
||||
Left message -> do
|
||||
when (errorCodeOf message == "unauthorized") (liftIO (endEdit ws >> closeWebSession ws))
|
||||
serverError message
|
||||
Right tables -> pure tables
|
||||
case mapM (editStatement infos) (V.toList entries) of
|
||||
Left message -> editFailure "active" ("bad_request: " <> message) Nothing False
|
||||
Right statements -> do
|
||||
reset <- liftIO (runStatement (wsEditor ws) "ROLLBACK TO SAVEPOINT web_edit_base")
|
||||
case reset of
|
||||
Left message -> do
|
||||
liftIO (endEdit ws >> closeClient (wsEditorClient ws))
|
||||
editFailure "lost" message Nothing True
|
||||
finish
|
||||
Right _ -> pure ()
|
||||
staged <- liftIO (runEditStatements (wsEditor ws) 0 statements)
|
||||
case staged of
|
||||
Left (index, message) -> resetEditAfterFailure ws (Just index) message
|
||||
Right () -> do
|
||||
committed <- liftIO (runStatement (wsEditor ws) "COMMIT")
|
||||
case committed of
|
||||
Right _ -> do
|
||||
let result = object ["ok" .= True, "applied" .= V.length entries, "state" .= ("committed" :: Text)]
|
||||
liftIO (endEdit ws >> writeIORef (wsEditReceipt ws) (Just (owner, A.Object o, result)))
|
||||
json result
|
||||
Left message | errorCodeOf message == "serialization_failure" -> do
|
||||
rolledBack <- liftIO (runStatement (wsEditor ws) "ROLLBACK")
|
||||
liftIO (endEdit ws)
|
||||
case rolledBack of
|
||||
Right _ -> editFailure "rolled_back" message Nothing True
|
||||
Left err -> do
|
||||
liftIO (closeClient (wsEditorClient ws))
|
||||
editFailure "lost" (message <> "; rollback failed: " <> err) Nothing True
|
||||
Left message | errorCodeOf message `elem` ["query_error", "bad_request", "forbidden"] -> resetEditAfterFailure ws Nothing message
|
||||
Left message -> do
|
||||
liftIO (endEdit ws >> closeClient (wsEditorClient ws))
|
||||
editFailure "lost" (message <> "; commit outcome requires reload") Nothing True
|
||||
_ -> reply400 "bad_request" "expected edit identity and a changes array"
|
||||
|
||||
-- | 撤销页面持有的编辑事务。
|
||||
rollbackEditH :: AppEnv -> ActionM ()
|
||||
rollbackEditH env = do
|
||||
ws <- requireSession env
|
||||
withJsonObject env $ \o -> case editIdentity o of
|
||||
Left message -> reply400 "bad_request" message
|
||||
Right identity -> do
|
||||
active <- liftIO (readIORef (wsEdit ws))
|
||||
case active of
|
||||
Nothing -> do
|
||||
receipt <- liftIO (readIORef (wsEditReceipt ws))
|
||||
let stateName = case receipt of
|
||||
Just (owner, _, _) | owner == fst identity -> "committed" :: Text
|
||||
_ -> "rolled_back"
|
||||
json (object ["state" .= stateName])
|
||||
Just _ -> do
|
||||
requireEdit ws identity
|
||||
result <- liftIO (runStatement (wsEditor ws) "ROLLBACK")
|
||||
liftIO (endEdit ws)
|
||||
case result of
|
||||
Left message -> liftIO (closeClient (wsEditorClient ws)) >> serverError message
|
||||
Right _ -> json (object ["state" .= ("rolled_back" :: Text)])
|
||||
|
||||
-- | 读取 SQL 控制台的请求所有者。
|
||||
consoleOwner :: ActionM Text
|
||||
consoleOwner = do
|
||||
owner <- maybe "legacy" TL.toStrict <$> header "X-ChuSQL-Console"
|
||||
if T.null owner || T.length owner > 128 then apiError "bad_request" "invalid console identity" else pure owner
|
||||
|
||||
-- | 回滚关闭页面或标签的控制台事务。
|
||||
rollbackConsoleH :: AppEnv -> ActionM ()
|
||||
rollbackConsoleH env = do
|
||||
ws <- requireSession env
|
||||
withJsonObject env $ \o -> case textField "console" o of
|
||||
Left message -> reply400 "bad_request" message
|
||||
Right owner -> do
|
||||
active <- liftIO (readIORef (wsConsole ws))
|
||||
case active of
|
||||
Nothing -> status status204
|
||||
Just (existing, _) | existing /= owner -> apiError "conflict" "another console owns this transaction"
|
||||
Just _ -> do
|
||||
result <- liftIO (runStatement (wsSession ws) "ROLLBACK")
|
||||
case result of
|
||||
Left message -> serverError message
|
||||
Right _ -> liftIO (writeIORef (wsConsole ws) Nothing) >> status status204
|
||||
|
||||
-- | 查询接口:校验请求体后执行 SQL。
|
||||
queryH :: AppEnv -> ActionM ()
|
||||
queryH env = do
|
||||
ws <- requireSession env
|
||||
@@ -1447,6 +1723,15 @@ executeSql ws live sql = do
|
||||
case result of
|
||||
Left message -> serverError message
|
||||
Right res -> do
|
||||
owner <- consoleOwner
|
||||
case parseStatement (T.unpack sql) of
|
||||
Right BeginTransaction -> do
|
||||
database <- liftIO (sessionDatabase (wsSession ws))
|
||||
liftIO (writeIORef (wsConsole ws) (Just (owner, database)))
|
||||
Right CommitTransaction -> liftIO (writeIORef (wsConsole ws) Nothing)
|
||||
Right RollbackTransaction -> liftIO (writeIORef (wsConsole ws) Nothing)
|
||||
_ -> pure ()
|
||||
transaction <- liftIO (readIORef (wsConsole ws))
|
||||
let shown = take (lvMaxRows live) (qrRows res)
|
||||
json
|
||||
( object
|
||||
@@ -1455,6 +1740,7 @@ executeSql ws live sql = do
|
||||
, "rowCount" .= qrRowCount res
|
||||
, "truncated" .= (qrTruncated res || qrRowCount res > lvMaxRows live)
|
||||
, "database" .= qrDatabase res
|
||||
, "transaction" .= (transaction /= Nothing)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1516,6 +1802,7 @@ valueJson (VInt n) = A.Number (fromIntegral n)
|
||||
valueJson (VFloat d) = A.Number (fromFloatDigits d)
|
||||
valueJson (VStr s) = A.String (T.pack s)
|
||||
valueJson (VBool b) = A.Bool b
|
||||
valueJson value@VRuntime{} = valueToJSON value
|
||||
|
||||
-- | 一张表的线上信息:列、行数、索引与统计
|
||||
tableInfoJson :: TableInfo -> A.Value
|
||||
|
||||
+61
-16
@@ -2,6 +2,9 @@
|
||||
// 请求都带 X-ChuSQL-Database 头;401 时广播登录事件。
|
||||
|
||||
export const AUTH_EVENT = 'chusql-auth-required';
|
||||
let editing = null;
|
||||
let consoleTransaction = null;
|
||||
const pageOwner = [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// 是否是普通对象。
|
||||
function isObject(value) {
|
||||
@@ -12,6 +15,9 @@ function isObject(value) {
|
||||
async function request(url, init, database) {
|
||||
const headers = new Headers(init && init.headers ? init.headers : undefined);
|
||||
if (database) headers.set('X-ChuSQL-Database', database);
|
||||
if (editing && editing.database === database && url.startsWith('/api/tables') && (!init || init.method === 'GET')) {
|
||||
headers.set('X-ChuSQL-Edit', editing.id);
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { credentials: 'same-origin', ...(init ?? {}), headers });
|
||||
@@ -31,7 +37,10 @@ async function request(url, init, database) {
|
||||
const message = isObject(body) && typeof body.message === 'string'
|
||||
? body.message
|
||||
: `请求失败(HTTP ${response.status})。`;
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
error.result = body;
|
||||
error.code = body?.error;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -215,33 +224,68 @@ export async function deleteRow(database, table, id) {
|
||||
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('DELETE'), database);
|
||||
}
|
||||
|
||||
// 逐条提交,遇到第一条失败就停,返回已经成功了几条。
|
||||
export async function commitChanges(database, changes) {
|
||||
let applied = 0;
|
||||
for (const [index, change] of changes.entries()) {
|
||||
try {
|
||||
if (change.op === 'insert') await insertRow(database, change.table, change.values);
|
||||
else if (change.op === 'delete') await deleteRow(database, change.table, change.pk);
|
||||
else await updateRow(database, change.table, change.pk, change.set);
|
||||
applied += 1;
|
||||
} catch (error) {
|
||||
return { ok: false, applied, errors: [{ index, message: error instanceof Error ? error.message : String(error) }] };
|
||||
}
|
||||
// 为页面开启固定数据库的编辑快照。
|
||||
export async function beginEdit(id, database) {
|
||||
await request('/api/edit/begin', jsonInit('POST', { id, database }));
|
||||
editing = { id, database };
|
||||
}
|
||||
|
||||
// 在服务端事务中原子提交编辑集。
|
||||
export async function commitChanges(id, database, changes) {
|
||||
try {
|
||||
const result = await request('/api/edit/commit', jsonInit('POST', { id, database, changes }));
|
||||
if (!isObject(result) || result.ok !== true || result.state !== 'committed') throw new Error('服务器未返回可确认的提交结果。');
|
||||
editing = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isObject(error.result) && typeof error.result.state === 'string') return error.result;
|
||||
return { ok: false, applied: 0, state: error.result ? 'lost' : 'unknown',
|
||||
reloadRequired: Boolean(error.result), message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 回滚编辑快照并清除本地事务标识。
|
||||
export async function rollbackEdit(id, database) {
|
||||
const result = await request('/api/edit/rollback', jsonInit('POST', { id, database }));
|
||||
editing = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
// 忘记已失效的登录会话编辑标识。
|
||||
export function forgetEdit() {
|
||||
editing = null;
|
||||
consoleTransaction = null;
|
||||
}
|
||||
|
||||
// 标签关闭时回滚它持有的控制台事务。
|
||||
export async function rollbackConsole(tabId) {
|
||||
if (!consoleTransaction || consoleTransaction.console !== `${pageOwner}:${tabId ?? 'default'}`) return;
|
||||
await request('/api/query/rollback', jsonInit('POST', { console: consoleTransaction.console }));
|
||||
consoleTransaction = null;
|
||||
}
|
||||
|
||||
// 页面离开时请求清理控制台事务。
|
||||
export function closeConsoleOnPagehide() {
|
||||
if (consoleTransaction) {
|
||||
navigator.sendBeacon('/api/query/rollback', new Blob([JSON.stringify({ console: consoleTransaction.console })], { type: 'application/json' }));
|
||||
}
|
||||
return { ok: true, applied };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 查询
|
||||
|
||||
// 执行 SQL 并规整结果。
|
||||
export async function query(sql, database) {
|
||||
export async function query(sql, database, tabId) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const body = await request('/api/query', jsonInit('POST', { sql }), database);
|
||||
const console = `${pageOwner}:${tabId ?? 'default'}`;
|
||||
const init = jsonInit('POST', { sql });
|
||||
init.headers['X-ChuSQL-Console'] = console;
|
||||
const body = await request('/api/query', init, database);
|
||||
if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) {
|
||||
throw new Error('服务器返回的查询结果无法识别。');
|
||||
}
|
||||
const durationMs = performance.now() - started;
|
||||
consoleTransaction = body.transaction === true ? { console, database } : null;
|
||||
return {
|
||||
type: body.columns.length ? 'rows' : 'affected',
|
||||
columns: body.columns,
|
||||
@@ -249,6 +293,7 @@ export async function query(sql, database) {
|
||||
affected: typeof body.rowCount === 'number' ? body.rowCount : 0,
|
||||
truncated: body.truncated === true,
|
||||
database: typeof body.database === 'string' ? body.database : undefined,
|
||||
transactionActive: body.transaction === true,
|
||||
durationMs,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
+185
-40
@@ -46,6 +46,12 @@ const state = {
|
||||
tabs: [],
|
||||
activeId: undefined,
|
||||
changes: createEmptyChangeSet(),
|
||||
edit: null,
|
||||
editStarting: null,
|
||||
editAttempt: null,
|
||||
editBusy: false,
|
||||
editNeedsReload: false,
|
||||
editUncertain: null,
|
||||
insertSeq: 0,
|
||||
messages: [],
|
||||
tabSeq: 0,
|
||||
@@ -301,8 +307,8 @@ function renderSidebar() {
|
||||
<section class="side-section">
|
||||
<div class="side-title">变更</div>
|
||||
<div class="side-row">
|
||||
<button data-action="commit"${count ? '' : ' disabled'}>提交 ${count}</button>
|
||||
<button data-action="rollback"${count ? '' : ' disabled'}>撤销</button>
|
||||
<button data-action="commit"${count && !state.editBusy && !state.editNeedsReload ? '' : ' disabled'}>提交 ${count}</button>
|
||||
<button data-action="rollback"${(state.edit || state.editAttempt) && !state.editBusy ? '' : ' disabled'}>撤销</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
@@ -364,7 +370,9 @@ function renderTableTab(el, tab) {
|
||||
}
|
||||
const columns = data.columns;
|
||||
const keyColumn = columns.find((column) => column.name === 'id');
|
||||
const editable = Boolean(keyColumn);
|
||||
const editable = Boolean(keyColumn) && tab.table !== accountsTable && Boolean(state.edit)
|
||||
&& data.editId === state.edit.id && !data.loading
|
||||
&& !state.editBusy && !state.editNeedsReload && !state.editUncertain;
|
||||
const filters = data.filters ?? {};
|
||||
const count = changeCount(state.changes);
|
||||
|
||||
@@ -413,8 +421,8 @@ function renderTableTab(el, tab) {
|
||||
<button data-action="insert-row"${editable ? '' : ' disabled'}>+ 新增行</button>
|
||||
<button data-action="delete-selected"${editable && (tab.selected?.size ?? 0) ? '' : ' disabled'}>删除选中</button>
|
||||
<span class="sep"></span>
|
||||
<button class="primary" data-action="commit"${count ? '' : ' disabled'}>提交 (${count})</button>
|
||||
<button data-action="rollback"${count ? '' : ' disabled'}>撤销</button>
|
||||
<button class="primary" data-action="commit"${count && !state.editBusy && !state.editNeedsReload ? '' : ' disabled'}>提交 (${count})</button>
|
||||
<button data-action="rollback"${(state.edit || state.editAttempt) && !state.editBusy ? '' : ' disabled'}>撤销</button>
|
||||
<span class="sep"></span>
|
||||
<button data-action="reload-table">刷新</button>
|
||||
<span class="flex-spacer"></span>
|
||||
@@ -438,7 +446,7 @@ function renderTableTab(el, tab) {
|
||||
${data.rows.length || stagedInserts.length ? '' : '<p class="empty">这张表没有数据。</p>'}
|
||||
</div>
|
||||
${data.loading ? '<div class="grid-loading">加载中…</div>' : ''}
|
||||
${editable ? '' : '<p class="notice">这张表没有 id 列,只能浏览。</p>'}
|
||||
${editable ? '' : '<p class="notice">当前表或事务状态仅允许浏览;系统身份请通过 SQL 管理。</p>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -650,8 +658,8 @@ function renderBottom() {
|
||||
<button class="${state.bottomTab === 'changes' ? 'active' : ''}" data-action="bottom-tab" data-tab="changes">待提交变更 (${count})</button>
|
||||
<button class="${state.bottomTab === 'messages' ? 'active' : ''}" data-action="bottom-tab" data-tab="messages">消息 (${state.messages.length})</button>
|
||||
<span class="flex-spacer"></span>
|
||||
<button data-action="commit"${count ? '' : ' disabled'}>提交全部</button>
|
||||
<button data-action="rollback"${count ? '' : ' disabled'}>撤销全部</button>
|
||||
<button data-action="commit"${count && !state.editBusy && !state.editNeedsReload ? '' : ' disabled'}>提交全部</button>
|
||||
<button data-action="rollback"${(state.edit || state.editAttempt) && !state.editBusy ? '' : ' disabled'}>撤销全部</button>
|
||||
<button data-action="copy-sql"${count ? '' : ' disabled'}>复制 SQL</button>
|
||||
</div>
|
||||
<div class="bottom-body">${state.bottomTab === 'changes' ? changeList : messages}</div>`;
|
||||
@@ -694,19 +702,36 @@ async function loadWorkspace() {
|
||||
|
||||
// 切换当前数据库。
|
||||
async function switchDatabase(name) {
|
||||
if (state.editBusy || state.editStarting) return;
|
||||
if (name === state.database) return;
|
||||
if (changeCount(state.changes) > 0) {
|
||||
log('warn', '请先提交或撤销当前库的修改,再切换数据库。');
|
||||
renderSidebar();
|
||||
return;
|
||||
}
|
||||
const previousEdit = state.edit ?? state.editAttempt;
|
||||
state.editBusy = true;
|
||||
afterChange();
|
||||
try {
|
||||
if (previousEdit) await api.rollbackEdit(previousEdit.id, previousEdit.database);
|
||||
} catch (error) {
|
||||
state.editBusy = false;
|
||||
fail(error);
|
||||
afterChange();
|
||||
return;
|
||||
}
|
||||
state.edit = null;
|
||||
state.editAttempt = null;
|
||||
const result = await api.query(`USE ${name}`, state.database);
|
||||
state.editBusy = false;
|
||||
if (result.type === 'error') {
|
||||
fail(new Error(result.message));
|
||||
renderSidebar();
|
||||
return;
|
||||
}
|
||||
state.database = result.database ?? name;
|
||||
state.tabs = state.tabs.filter((entry) => entry.kind !== 'table');
|
||||
state.activeId = state.tabs[0]?.id;
|
||||
log('info', `已切换到数据库 ${state.database}`);
|
||||
await loadWorkspace();
|
||||
renderEditor();
|
||||
@@ -732,10 +757,12 @@ async function openTable(database, name) {
|
||||
|
||||
// 加载表标签的一页数据。
|
||||
async function loadTableData(tab) {
|
||||
if (state.editBusy || state.editNeedsReload || state.editUncertain) return;
|
||||
const data = tab.data;
|
||||
data.loading = true;
|
||||
renderEditor();
|
||||
try {
|
||||
if (tab.table !== accountsTable) await ensureEdit(tab.database);
|
||||
const meta = await api.getTable(tab.database, tab.table);
|
||||
const filters = Object.entries(data.filters ?? {})
|
||||
.filter(([, expression]) => String(expression).trim().length > 0)
|
||||
@@ -756,6 +783,7 @@ async function loadTableData(tab) {
|
||||
data.offset = offset;
|
||||
data.pageSize = pageSize;
|
||||
data.error = null;
|
||||
data.editId = state.edit?.id;
|
||||
// 服务端做等值过滤,界面上的 like / 比较表达式再在本地收敛一次
|
||||
data.rows = filters.length
|
||||
? page.rows.filter((row) => filters.every((filter) => matchesFilter(row[filter.column], filter.expression)))
|
||||
@@ -770,6 +798,28 @@ async function loadTableData(tab) {
|
||||
}
|
||||
}
|
||||
|
||||
// 在首个数据表加载时固定编辑快照。
|
||||
async function ensureEdit(database) {
|
||||
if (database !== state.database) throw new Error('请切换到表所属数据库后重新打开。');
|
||||
if (state.edit) {
|
||||
if (state.edit.database !== database) throw new Error('请先撤销当前数据库的编辑事务。');
|
||||
return;
|
||||
}
|
||||
if (!state.editStarting) {
|
||||
const edit = state.editAttempt ?? { id: [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, '0')).join(''), database };
|
||||
if (edit.database !== database) throw new Error('请先撤销之前的编辑快照,再切换数据库。');
|
||||
state.editAttempt = edit;
|
||||
const session = state.session;
|
||||
state.editStarting = api.beginEdit(edit.id, database).then(() => {
|
||||
if (state.session !== session) throw new Error('登录会话已更换,请重新加载。');
|
||||
state.edit = edit;
|
||||
state.editAttempt = null;
|
||||
})
|
||||
.finally(() => { state.editStarting = null; });
|
||||
}
|
||||
await state.editStarting;
|
||||
}
|
||||
|
||||
// 重新加载所有表标签。
|
||||
async function reloadOpenTables() {
|
||||
for (const tab of state.tabs) {
|
||||
@@ -790,6 +840,7 @@ function unstageRow(table, pk) {
|
||||
|
||||
// 暂存单元格修改。
|
||||
function stageUpdate(table, database, pk, column, oldValue, newValue) {
|
||||
if (!canEdit(database)) return;
|
||||
if (oldValue === newValue) state.changes.updates.delete(changeKey(table, pk, column));
|
||||
else state.changes.updates.set(changeKey(table, pk, column), { table, database, pk, column, oldValue, newValue });
|
||||
afterChange();
|
||||
@@ -797,6 +848,7 @@ function stageUpdate(table, database, pk, column, oldValue, newValue) {
|
||||
|
||||
// 暂存新增行。
|
||||
function stageInsert(table, database, values) {
|
||||
if (!canEdit(database)) return;
|
||||
state.insertSeq += 1;
|
||||
const tempId = `new-${state.insertSeq}`;
|
||||
state.changes.inserts.set(insertKey(table, tempId), { table, database, tempId, values });
|
||||
@@ -805,6 +857,7 @@ function stageInsert(table, database, values) {
|
||||
|
||||
// 暂存删除行。
|
||||
function stageDelete(table, database, pk, row) {
|
||||
if (!canEdit(database)) return;
|
||||
unstageRow(table, pk);
|
||||
state.changes.deletes.set(deleteKey(table, pk), { table, database, pk, row });
|
||||
afterChange();
|
||||
@@ -819,6 +872,7 @@ function afterChange() {
|
||||
|
||||
// 撤下一条暂存变更。
|
||||
function removeStaged(key) {
|
||||
if (!canEdit(state.database)) return;
|
||||
if (state.changes.updates.has(key)) state.changes.updates.delete(key);
|
||||
else if (state.changes.inserts.has(key)) state.changes.inserts.delete(key);
|
||||
else if (state.changes.deletes.has(key)) state.changes.deletes.delete(key);
|
||||
@@ -826,48 +880,70 @@ function removeStaged(key) {
|
||||
}
|
||||
|
||||
// 撤销全部暂存变更。
|
||||
function rollbackAll() {
|
||||
state.changes = createEmptyChangeSet();
|
||||
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
|
||||
log('info', '已撤销全部待提交变更。');
|
||||
async function rollbackAll() {
|
||||
if (state.editBusy || state.editStarting) return;
|
||||
state.editBusy = true;
|
||||
afterChange();
|
||||
try {
|
||||
const edit = state.edit ?? state.editAttempt;
|
||||
const result = edit ? await api.rollbackEdit(edit.id, edit.database) : null;
|
||||
state.edit = null;
|
||||
state.editAttempt = null;
|
||||
state.editNeedsReload = false;
|
||||
state.editUncertain = null;
|
||||
state.changes = createEmptyChangeSet();
|
||||
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
|
||||
log('info', result?.state === 'committed' ? '先前提交已成功,现重新加载数据。' : '已回滚编辑事务,重新加载数据。');
|
||||
state.editBusy = false;
|
||||
await loadWorkspace();
|
||||
await reloadOpenTables();
|
||||
} catch (error) {
|
||||
state.editNeedsReload = true;
|
||||
fail(error);
|
||||
} finally {
|
||||
state.editBusy = false;
|
||||
afterChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 检查编辑事务与页面操作状态。
|
||||
function canEdit(database) {
|
||||
return Boolean(state.edit) && state.edit.database === database && !state.editBusy
|
||||
&& !state.editNeedsReload && !state.editUncertain;
|
||||
}
|
||||
|
||||
// 提交全部暂存变更。
|
||||
async function commitAll() {
|
||||
const payload = toCommitPayload(state.changes);
|
||||
if (state.editBusy || state.editStarting) return;
|
||||
if (state.editNeedsReload || !state.edit) {
|
||||
fail(new Error('编辑快照已失效,请撤销并重新加载后编辑。'));
|
||||
return;
|
||||
}
|
||||
const payload = state.editUncertain ?? toCommitPayload(state.changes);
|
||||
if (!payload.length) {
|
||||
log('info', '没有待提交的变更。');
|
||||
return;
|
||||
}
|
||||
const database = state.database;
|
||||
const result = await api.commitChanges(database, payload);
|
||||
state.editBusy = true;
|
||||
afterChange();
|
||||
const edit = state.edit;
|
||||
const result = await api.commitChanges(edit.id, edit.database, payload);
|
||||
if (state.edit !== edit) { state.editBusy = false; return; }
|
||||
if (result.ok) {
|
||||
state.edit = null;
|
||||
state.editUncertain = null;
|
||||
state.changes = createEmptyChangeSet();
|
||||
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
|
||||
log('info', `已提交 ${result.applied} 条变更。`);
|
||||
setStatus(`已提交 ${result.applied} 条变更`);
|
||||
} else {
|
||||
const failed = result.errors?.[0];
|
||||
// 成功的部分不再保留,失败的那条留在面板上等修正
|
||||
state.changes = createEmptyChangeSet();
|
||||
payload.slice(result.applied).forEach((change) => {
|
||||
if (change.op === 'insert') {
|
||||
state.insertSeq += 1;
|
||||
const tempId = `new-${state.insertSeq}`;
|
||||
state.changes.inserts.set(insertKey(change.table, tempId), { table: change.table, database, tempId, values: change.values });
|
||||
} else if (change.op === 'delete') {
|
||||
state.changes.deletes.set(deleteKey(change.table, change.pk), { table: change.table, database, pk: change.pk, row: {} });
|
||||
} else {
|
||||
for (const [column, value] of Object.entries(change.set)) {
|
||||
state.changes.updates.set(changeKey(change.table, change.pk, column), {
|
||||
table: change.table, database, pk: change.pk, column, oldValue: undefined, newValue: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
log('error', `提交在第 ${(failed?.index ?? 0) + 1} 条失败:${failed?.message ?? '未知错误'}(已成功 ${result.applied} 条)`);
|
||||
state.editNeedsReload = result.reloadRequired === true;
|
||||
state.editUncertain = result.state === 'unknown' ? payload : null;
|
||||
log('error', `${result.message ?? '提交失败'}(事务状态:${result.state})。${state.editNeedsReload
|
||||
? result.state === 'lost' ? '连接已失效,请重新登录,确认数据后重新编辑。' : '请撤销并重新加载,禁止覆盖其他会话修改。' : state.editUncertain
|
||||
? '结果尚未确认,编辑已冻结;再次提交仅查询同一提交结果。' : '全部修改已回退,编辑内容保留,可修正后重试。'}`);
|
||||
}
|
||||
state.editBusy = false;
|
||||
await loadWorkspace();
|
||||
await reloadOpenTables();
|
||||
afterChange();
|
||||
@@ -877,10 +953,31 @@ async function commitAll() {
|
||||
|
||||
// 运行 SQL 并展示结果。
|
||||
async function runSql(tab) {
|
||||
if (state.editBusy || state.editStarting || state.editUncertain || state.editNeedsReload) return;
|
||||
if (changeCount(state.changes)) {
|
||||
log('warn', '请先提交或撤销表格修改,再运行 SQL。');
|
||||
return;
|
||||
}
|
||||
const sql = String(tab.sql ?? '').trim();
|
||||
if (!sql) return;
|
||||
state.editBusy = true;
|
||||
afterChange();
|
||||
const previousEdit = state.edit ?? state.editAttempt;
|
||||
if (previousEdit) {
|
||||
try {
|
||||
await api.rollbackEdit(previousEdit.id, previousEdit.database);
|
||||
state.edit = null;
|
||||
state.editAttempt = null;
|
||||
} catch (error) {
|
||||
state.editBusy = false;
|
||||
fail(error);
|
||||
afterChange();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStatus('执行中…');
|
||||
const result = await api.query(sql, tab.database);
|
||||
const result = await api.query(sql, tab.database, tab.id);
|
||||
state.editBusy = false;
|
||||
tab.result = result;
|
||||
if (result.type === 'error') {
|
||||
log('error', result.message ?? '执行失败');
|
||||
@@ -888,6 +985,7 @@ async function runSql(tab) {
|
||||
} else {
|
||||
if (result.database && result.database !== state.database) {
|
||||
state.database = result.database;
|
||||
state.tabs = state.tabs.filter((entry) => entry.kind !== 'table');
|
||||
tab.database = result.database;
|
||||
await loadWorkspace();
|
||||
}
|
||||
@@ -897,8 +995,10 @@ async function runSql(tab) {
|
||||
} else {
|
||||
log('info', `语句影响 ${result.affected ?? 0} 行`);
|
||||
setStatus(`影响 ${result.affected ?? 0} 行`);
|
||||
await loadWorkspace();
|
||||
await reloadOpenTables();
|
||||
if (!result.transactionActive) {
|
||||
await loadWorkspace();
|
||||
await reloadOpenTables();
|
||||
}
|
||||
}
|
||||
}
|
||||
renderEditor();
|
||||
@@ -912,15 +1012,22 @@ function activateTab(id) {
|
||||
const tab = activeTab();
|
||||
if (tab?.kind === 'sql') tab.database = state.database;
|
||||
if (tab?.kind === 'settings' && !state.serverSettings) void loadServerSettings();
|
||||
if (tab?.kind === 'table' && !state.edit && tab.table !== accountsTable) void loadTableData(tab);
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
// 关闭指定标签。
|
||||
function closeTabById(id) {
|
||||
if (state.tabs.find((tab) => tab.id === id)?.kind === 'sql') {
|
||||
void api.rollbackConsole(id).catch(fail);
|
||||
}
|
||||
const closed = closeTab(state.tabs, id, state.activeId);
|
||||
state.tabs = closed.tabs;
|
||||
state.activeId = closed.activeId;
|
||||
renderTabs();
|
||||
if (!state.tabs.some((tab) => tab.kind === 'table') && !changeCount(state.changes) && (state.edit || state.editAttempt)) {
|
||||
void rollbackAll();
|
||||
}
|
||||
}
|
||||
|
||||
// 新开一个查询标签。
|
||||
@@ -1066,6 +1173,10 @@ function deleteSelected(tab) {
|
||||
|
||||
// 就地编辑单元格:提交或放弃。
|
||||
function beginCellEdit(cell, options) {
|
||||
if (/^runtime\(\[1,\[[567],/.test(String(options.type))) {
|
||||
setStatus('复合值请通过 SQL 控制台修改');
|
||||
return;
|
||||
}
|
||||
if (cell.querySelector('input')) return;
|
||||
const input = document.createElement('input');
|
||||
input.className = 'cell-input';
|
||||
@@ -1292,6 +1403,7 @@ function onClick(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
const action = target.dataset.action;
|
||||
if (state.editBusy || state.editStarting) return;
|
||||
const tab = activeTab();
|
||||
|
||||
// 没有选库时,需要当前库的动作先拦下来(服务端也会拒,这里给人话)
|
||||
@@ -1305,13 +1417,19 @@ function onClick(event) {
|
||||
void (async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
// 退出失败也要回到登录页
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
state.session = null;
|
||||
state.tabs = [];
|
||||
state.activeId = undefined;
|
||||
state.changes = createEmptyChangeSet();
|
||||
state.edit = null;
|
||||
state.editAttempt = null;
|
||||
state.editNeedsReload = false;
|
||||
state.editUncertain = null;
|
||||
api.forgetEdit();
|
||||
renderLogin('已退出登录。', 'info');
|
||||
})();
|
||||
return;
|
||||
@@ -1450,7 +1568,7 @@ function onClick(event) {
|
||||
void commitAll();
|
||||
return;
|
||||
case 'rollback':
|
||||
rollbackAll();
|
||||
void rollbackAll();
|
||||
return;
|
||||
case 'copy-sql': {
|
||||
const text = changesSql(state.changes).join('\n');
|
||||
@@ -1493,10 +1611,12 @@ function onClick(event) {
|
||||
if (!meta || !row) return;
|
||||
const staged = state.changes.updates.get(changeKey(tab.table, pk, column));
|
||||
const oldValue = staged ? staged.oldValue : row[column.name];
|
||||
const editId = state.edit?.id;
|
||||
beginCellEdit(target, {
|
||||
type: meta.type,
|
||||
currentValue: staged ? staged.newValue : row[column.name],
|
||||
commit: (value) => {
|
||||
if (state.edit?.id !== editId) return;
|
||||
if (value === undefined) renderEditor();
|
||||
else stageUpdate(tab.table, tab.database, pk, column, oldValue, value);
|
||||
},
|
||||
@@ -1513,6 +1633,7 @@ function onClick(event) {
|
||||
type: meta ? meta.type : 'str',
|
||||
currentValue: insert.values[column],
|
||||
commit: (value) => {
|
||||
if (!canEdit(tab.database)) return;
|
||||
if (value === undefined) renderEditor();
|
||||
else {
|
||||
insert.values[column] = value;
|
||||
@@ -1617,9 +1738,33 @@ window.addEventListener(AUTH_EVENT, () => {
|
||||
state.session = null;
|
||||
state.tabs = [];
|
||||
state.activeId = undefined;
|
||||
state.changes = createEmptyChangeSet();
|
||||
state.edit = null;
|
||||
state.editAttempt = null;
|
||||
state.editStarting = null;
|
||||
state.editUncertain = null;
|
||||
state.editNeedsReload = false;
|
||||
api.forgetEdit();
|
||||
renderLogin('会话已过期,请重新登录。', 'warn');
|
||||
});
|
||||
|
||||
// 离开页面时请求回滚仍持有的编辑快照。
|
||||
window.addEventListener('pagehide', () => {
|
||||
api.closeConsoleOnPagehide();
|
||||
const edit = state.edit ?? state.editAttempt;
|
||||
if (edit && !state.editBusy) {
|
||||
navigator.sendBeacon('/api/edit/rollback', new Blob([JSON.stringify(edit)], { type: 'application/json' }));
|
||||
}
|
||||
});
|
||||
|
||||
// 未提交或结果未知时提示页面关闭。
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (changeCount(state.changes) || state.editBusy) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
root.addEventListener('click', onClick);
|
||||
root.addEventListener('change', onChange);
|
||||
root.addEventListener('submit', onSubmit);
|
||||
|
||||
@@ -23,6 +23,8 @@ export const accountsTable = '__system_users';
|
||||
// 值转 SQL 字面量(字符串加引号转义)。
|
||||
export function sqlLiteral(value) {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
const runtime = runtimeEnvelope(value);
|
||||
if (runtime) return runtimeValueText(runtime[1], runtime[2], true);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
}
|
||||
@@ -235,6 +237,11 @@ export function isBooleanType(type) {
|
||||
|
||||
// 类型的显示名。
|
||||
export function typeLabel(type) {
|
||||
if (String(type).startsWith('runtime(') && String(type).endsWith(')')) {
|
||||
const envelope = JSON.parse(String(type).slice(8, -1));
|
||||
if (!Array.isArray(envelope) || envelope.length !== 2 || envelope[0] !== 1) throw new Error('Unsupported runtime type encoding');
|
||||
return runtimeTypeText(envelope[1]);
|
||||
}
|
||||
const kind = columnKind(type);
|
||||
const label = typeLabels[kind];
|
||||
const args = String(type).includes('(') ? String(type).slice(String(type).indexOf('(')) : '';
|
||||
@@ -247,6 +254,8 @@ export function typeLabel(type) {
|
||||
// 单元格显示文本(空值用占位文本)。
|
||||
export function cellText(value, nullText) {
|
||||
if (value === null || value === undefined) return nullText;
|
||||
const runtime = runtimeEnvelope(value);
|
||||
if (runtime) return runtimeValueText(runtime[1], runtime[2], false);
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return String(value);
|
||||
}
|
||||
@@ -254,9 +263,60 @@ export function cellText(value, nullText) {
|
||||
// 编辑框里回填的文本(null 给空串)。
|
||||
export function editText(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const runtime = runtimeEnvelope(value);
|
||||
if (runtime && runtime[1][0] <= 4) return String(runtime[2]);
|
||||
if (runtime) return runtimeValueText(runtime[1], runtime[2], false);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// 读取版本化运行时值封装。
|
||||
function runtimeEnvelope(value) {
|
||||
if (!value || typeof value !== 'object' || !Object.hasOwn(value, '__chusql_runtime_v1')) return null;
|
||||
const envelope = value.__chusql_runtime_v1;
|
||||
if (!Array.isArray(envelope) || envelope.length !== 3 || envelope[0] !== 1) throw new Error('Unsupported runtime value encoding');
|
||||
return envelope;
|
||||
}
|
||||
|
||||
// 渲染运行时类型的 SQL 语法。
|
||||
function runtimeTypeText(type) {
|
||||
if (!Array.isArray(type) || type.length !== 2 || !Array.isArray(type[1])) throw new Error('Invalid runtime type identity');
|
||||
const [id, args] = type;
|
||||
if (!Number.isInteger(id)) throw new Error('Invalid runtime constructor identity');
|
||||
if (id >= 1 && id <= 4 && args.length === 0) return ['Int', 'Double', 'Bool', 'String'][id - 1];
|
||||
if (id === 5 && args.length === 1) return `[${runtimeTypeText(args[0])}]`;
|
||||
if (id === 6 && args.length === 1) return `Maybe ${runtimeTypeText(args[0])}`;
|
||||
if (id === 7) return `(${args.map(runtimeTypeText).join(', ')})`;
|
||||
throw new Error('Unknown runtime type identity');
|
||||
}
|
||||
|
||||
// 渲染复合值或对应的 SQL 构造器。
|
||||
function runtimeValueText(type, value, sql) {
|
||||
runtimeTypeText(type);
|
||||
const [id, args] = type;
|
||||
if (id === 1 || id === 2) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Invalid runtime numeric value');
|
||||
if (id === 1 && !Number.isInteger(value)) throw new Error('Invalid runtime integer value');
|
||||
const text = String(value);
|
||||
return sql && id === 2 && !/[.eE]/.test(text) ? `${text}.0` : text;
|
||||
}
|
||||
if (id === 3 && typeof value === 'boolean') return sql ? (value ? 'TRUE' : 'FALSE') : String(value);
|
||||
if (id === 4 && typeof value === 'string') return sql ? sqlLiteral(value) : JSON.stringify(value);
|
||||
if (!Array.isArray(value)) throw new Error('Invalid runtime compound value');
|
||||
if (id === 5) {
|
||||
const items = value.map((item) => runtimeValueText(args[0], item, sql)).join(', ');
|
||||
return sql ? `LIST<${runtimeTypeText(args[0])}>(${items})` : `[${items}]`;
|
||||
}
|
||||
if (id === 6 && value.length <= 1) {
|
||||
const item = value.length ? runtimeValueText(args[0], value[0], sql) : '';
|
||||
return sql ? `MAYBE<${runtimeTypeText(args[0])}>(${item})` : (value.length ? `Just (${item})` : 'Nothing');
|
||||
}
|
||||
if (id === 7 && value.length === args.length) {
|
||||
const items = value.map((item, index) => runtimeValueText(args[index], item, sql)).join(', ');
|
||||
return sql ? `TUPLE<${args.map(runtimeTypeText).join(', ')}>(${items})` : `(${items})`;
|
||||
}
|
||||
throw new Error('Runtime value type mismatch');
|
||||
}
|
||||
|
||||
// 编辑框文本转提交值(空串当 null)。
|
||||
export function parseEdited(raw, type) {
|
||||
const text = String(raw ?? '').trim();
|
||||
|
||||
@@ -179,7 +179,8 @@ gateSpec = do
|
||||
-- 静态目录与管理员名都不再写进配置:前者写死 static,后者由 --user 给(默认 root);
|
||||
-- 监听地址是活的配置项([server] listen_host / [web] listen_host),模板里可以留空靠内置默认
|
||||
template `shouldSatisfy` (not . T.isInfixOf "static_dir =")
|
||||
template `shouldSatisfy` (not . T.isInfixOf "user =")
|
||||
template `shouldSatisfy` (not . any ((== "user") . T.strip . fst . T.breakOn "=") . T.lines)
|
||||
template `shouldSatisfy` T.isInfixOf ("sudo_auth_user = " <> T.pack (show ("" :: String)))
|
||||
-- 管道传输退休后,两侧都不该再算套接字路径、也不该再认管名。
|
||||
it "neither side computes a socket path or a pipe name any more" $ do
|
||||
haskellIpc <- readUtf8 (".." </> "chusql-core" </> "engine" </> "src" </> "ChuSQL" </> "Core" </> "Engine" </> "Storage" </> "IPC.hs")
|
||||
|
||||
+312
-5
@@ -3,8 +3,12 @@
|
||||
module Main (main) where
|
||||
|
||||
import ChuSQL.Interface.Link (closeClient, connectClient)
|
||||
import ChuSQL.Interface.Auth (SessionPolicy (..), defaultSessionPolicy, lookupPayload, setPayloadPolicy)
|
||||
import ChuSQL.Interface.RateLimit (newRateLimiter)
|
||||
import ChuSQL.Interface.Session (authenticateSession, newSession, runStatement)
|
||||
import qualified ChuSQL.Interface.Actions as Actions
|
||||
import qualified ChuSQL.Core.Model as Model
|
||||
import ChuSQL.Core.Runtime
|
||||
import ChuSQL.Interface.Settings (
|
||||
applySettings,
|
||||
defaultOf,
|
||||
@@ -17,6 +21,7 @@ import ChuSQL.Interface.Settings (
|
||||
)
|
||||
import ChuSQL.Web.API (
|
||||
AppEnv (..),
|
||||
WebSession (..),
|
||||
Live (..),
|
||||
defaultLive,
|
||||
isIdentifier,
|
||||
@@ -27,8 +32,9 @@ import ChuSQL.Web.API (
|
||||
)
|
||||
import ChuSQL.Web.Static (contentTypeOf, safeRelative)
|
||||
import ChuSQL.Web.UISettings (validateUISettings)
|
||||
import Control.Concurrent (forkIO)
|
||||
import Control.Exception (IOException, bracket, try)
|
||||
import Control.Concurrent (forkFinally, forkIO)
|
||||
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
|
||||
import Control.Exception (IOException, bracket, throwIO, try)
|
||||
import Control.Monad (when)
|
||||
import Data.Aeson (encode, object, (.=))
|
||||
import qualified Data.Aeson as A
|
||||
@@ -39,6 +45,7 @@ import qualified Data.ByteString.Char8 as BSC
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Char (isDigit)
|
||||
import Data.List (isInfixOf)
|
||||
import Data.IORef (readIORef)
|
||||
import qualified Data.Map.Strict as Map
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
@@ -511,6 +518,17 @@ stopServer world = do
|
||||
-- | 纯函数用例
|
||||
unitSpec :: Spec
|
||||
unitSpec = describe "pure helpers" $ do
|
||||
it "rejects malformed runtime values before producing write SQL" $ do
|
||||
case resolveType builtinTypes (listType intType) of
|
||||
Left err -> expectationFailure err
|
||||
Right descriptor ->
|
||||
Actions.insertRowSql "t" [("xs", Model.VRuntime (descriptorId descriptor) (RList [RString "wrong"]))]
|
||||
`shouldSatisfy` either (const True) (const False)
|
||||
it "escapes nested runtime string literals" $ do
|
||||
case resolveType builtinTypes (listType stringType) of
|
||||
Left err -> expectationFailure err
|
||||
Right descriptor -> Actions.sqlLiteral (Model.VRuntime (descriptorId descriptor) (RList [RString "a'b"]))
|
||||
`shouldBe` Right "LIST<String>('a''b')"
|
||||
it "parses the session cookie out of a Cookie header" $ do
|
||||
parseCookieHeader sessionCookieName "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123"
|
||||
parseCookieHeader sessionCookieName "other=1" `shouldBe` Nothing
|
||||
@@ -835,17 +853,54 @@ accountSpec env = describe "the account table (system database, admin only)" $ d
|
||||
roleSpec :: AppEnv -> Spec
|
||||
roleSpec env = describe "roles and grants" $ do
|
||||
withApp (webApp env) $ do
|
||||
it "uses database scopes and owner permissions through REST and rejects private data" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin testDatabaseName
|
||||
ensureAccount admin "acl_web_owner" "Owner-Secret-1234"
|
||||
ensureAccount admin "acl_web_reader" "Reader-Secret-1234"
|
||||
ensureTable admin "acl_private" (encode (object ["name" .= ("acl_private" :: Text), "columns" .= [column "id" "int"]]))
|
||||
permission <- postAs admin "/api/roles/acl_web_owner/grants" (encode (object ["object" .= ("database:" <> testDatabaseName), "privileges" .= (["connect", "create"] :: [Text])]))
|
||||
expectStatusWith "database object grant" permission 200
|
||||
owner <- ordinaryHeaders "acl_web_owner" "Owner-Secret-1234"
|
||||
created <- postAs (withDb testDatabaseName owner) "/api/tables" (encode (object ["name" .= ("acl_owned" :: Text), "columns" .= [column "id" "int"]]))
|
||||
expectStatusWith "owner creates table" created 200
|
||||
denied <- testSql owner "SELECT * FROM acl_private"
|
||||
expectStatusWith "private table denied" denied 403
|
||||
grantDenied <- postAs (withDb testDatabaseName owner) "/api/roles/acl_web_reader/grants" (encode (object ["object" .= (testDatabaseName <> ".acl_private"), "privileges" .= (["select"] :: [Text])]))
|
||||
expectStatusWith "missing grant option" grantDenied 403
|
||||
delegated <- postAs (withDb testDatabaseName owner) "/api/roles/acl_web_reader/grants" (encode (object ["object" .= (testDatabaseName <> ".acl_owned"), "privileges" .= (["select"] :: [Text])]))
|
||||
expectStatusWith "owner delegates via REST" delegated 200
|
||||
reader <- ordinaryHeaders "acl_web_reader" "Reader-Secret-1234"
|
||||
visible <- testSql reader "SELECT * FROM acl_owned"
|
||||
expectStatusWith "delegated query" visible 200
|
||||
revoked <- testSql admin "REVOKE SELECT ON acl_owned FROM acl_web_reader"
|
||||
expectStatusWith "root revokes owner delegation" revoked 200
|
||||
invalidated <- testSql reader "SELECT * FROM acl_owned"
|
||||
expectStatusWith "revoked Web session" invalidated 401
|
||||
it "filters database visibility after PUBLIC CONNECT is revoked" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin "acl_closed"
|
||||
ensureAccount admin "acl_web_guest" "Guest-Secret-1234"
|
||||
closed <- postAs admin "/api/query" (encode (object ["sql" .= ("REVOKE CONNECT ON DATABASE acl_closed FROM PUBLIC" :: Text)]))
|
||||
expectStatusWith "close default connection" closed 200
|
||||
guest <- ordinaryHeaders "acl_web_guest" "Guest-Secret-1234"
|
||||
listed <- getAs guest "/api/databases"
|
||||
expectStatusWith "filtered database list" listed 200
|
||||
check (("acl_closed" `elem` map asText (items (jsonBody listed))) `shouldBe` False)
|
||||
refused <- getAs (withDb "acl_closed" guest) "/api/tables"
|
||||
expectStatusWith "CONNECT denied through Web" refused 403
|
||||
it "creates a role, grants a privilege, adds a member and drops it" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
ensureAccount hs "bob" "bob-Secret-123456"
|
||||
ensureTable (withDb testDatabaseName hs) "grant_probe" (encode (object ["name" .= ("grant_probe" :: Text), "columns" .= [column "id" "int"]]))
|
||||
created <- postAs hs "/api/roles" (encode (object ["name" .= ("analyst" :: Text)]))
|
||||
expectStatusWith "create role" created 200
|
||||
granted <-
|
||||
postAs
|
||||
hs
|
||||
"/api/roles/analyst/grants"
|
||||
(encode (object ["object" .= testDatabaseName, "privileges" .= (["select"] :: [Text])]))
|
||||
(encode (object ["object" .= (testDatabaseName <> ".grant_probe"), "privileges" .= (["select"] :: [Text])]))
|
||||
expectStatusWith "grant privilege" granted 200
|
||||
member <- postAs hs "/api/roles/analyst/members" (encode (object ["user" .= ("bob" :: Text)]))
|
||||
expectStatusWith "add member" member 200
|
||||
@@ -865,13 +920,14 @@ roleSpec env = describe "roles and grants" $ do
|
||||
it "carries the grant option through the API" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
ensureTable (withDb testDatabaseName hs) "grant_option_probe" (encode (object ["name" .= ("grant_option_probe" :: Text), "columns" .= [column "id" "int"]]))
|
||||
created <- postAs hs "/api/roles" (encode (object ["name" .= ("passer" :: Text)]))
|
||||
expectStatusWith "create passer" created 200
|
||||
granted <-
|
||||
postAs
|
||||
(withDb testDatabaseName hs)
|
||||
"/api/roles/passer/grants"
|
||||
(encode (object ["object" .= testDatabaseName, "privileges" .= (["select"] :: [Text]), "grantOption" .= True]))
|
||||
(encode (object ["object" .= (testDatabaseName <> ".grant_option_probe"), "privileges" .= (["select"] :: [Text]), "grantOption" .= True]))
|
||||
expectStatusWith "grant with option" granted 200
|
||||
listed <- getAs hs "/api/roles"
|
||||
expectStatusWith "list roles" listed 200
|
||||
@@ -890,6 +946,16 @@ roleSpec env = describe "roles and grants" $ do
|
||||
querySpec :: AppEnv -> Spec
|
||||
querySpec env = describe "the SQL console" $ do
|
||||
withApp (webApp env) $ do
|
||||
it "returns typed compound values through HTTP and TCP" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
result <- testSql hs "SELECT LIST<Int>(1, 2), length('abc')"
|
||||
expectStatusWith "typed SQL values" result 200
|
||||
let value = maybe A.Null id (nth 0 (firstRow result))
|
||||
envelope = at "__chusql_runtime_v1" value
|
||||
check (arrayLen envelope `shouldBe` 3)
|
||||
check (asInt (maybe A.Null id (nth 0 (items envelope))) `shouldBe` 1)
|
||||
check (asInt (maybe A.Null id (nth 1 (firstRow result))) `shouldBe` 3)
|
||||
it "runs DDL and DML and returns positional rows" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
@@ -916,9 +982,230 @@ querySpec env = describe "the SQL console" $ do
|
||||
ensureDatabase hs testDatabaseName
|
||||
res <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("SELEC nonsense" :: Text)]))
|
||||
expectStatusWith "syntax error" res 400
|
||||
it "redacts credentials from malformed SQL responses" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
let secret = "Web-Secret-DoNotEcho123!" :: Text
|
||||
res <- postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= ("CREATE USER broken IDENTIFIED BY '" <> secret <> "' trailing")]))
|
||||
expectStatusWith "credential syntax error" res 400
|
||||
check (BSC.isInfixOf "Web-Secret-DoNotEcho123!" (BL.toStrict (WT.simpleBody res)) `shouldBe` False)
|
||||
check (BSC.isInfixOf "redacted" (BL.toStrict (WT.simpleBody res)) `shouldBe` True)
|
||||
|
||||
-- IDE 设置 -------------------------------------------------------------------
|
||||
|
||||
-- | 发出编辑事务控制请求。
|
||||
editRequest :: [Header] -> BS.ByteString -> Text -> [A.Value] -> TestSession WT.SResponse
|
||||
editRequest hs path owner changes = postAs hs path (encode (object
|
||||
["id" .= owner, "database" .= testDatabaseName, "changes" .= changes]))
|
||||
|
||||
-- | 在测试库执行一条 SQL。
|
||||
testSql :: [Header] -> Text -> TestSession WT.SResponse
|
||||
testSql hs sql = postAs (withDb testDatabaseName hs) "/api/query" (encode (object ["sql" .= sql]))
|
||||
|
||||
-- | 生成整数行插入编辑。
|
||||
editInsert :: Text -> Int -> A.Value
|
||||
editInsert table key = object ["op" .= ("insert" :: Text), "table" .= table,
|
||||
"values" .= object ["id" .= key, "value" .= key]]
|
||||
|
||||
-- | 验证编辑原子性、隔离与失效清理。
|
||||
editSpec :: AppEnv -> Spec
|
||||
editSpec env = describe "web editing transactions" $ withApp (webApp env) $ do
|
||||
it "commits across tables atomically and deduplicates successful retries" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
madeA <- testSql hs "CREATE TABLE edit_atomic_a (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create first table" madeA 200
|
||||
madeB <- testSql hs "CREATE TABLE edit_atomic_b (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create second table" madeB 200
|
||||
begun <- editRequest hs "/api/edit/begin" "atomic" []
|
||||
expectStatusWith "begin" begun 200
|
||||
let changes = [editInsert "edit_atomic_a" 1, editInsert "edit_atomic_b" 2]
|
||||
committed <- editRequest hs "/api/edit/commit" "atomic" changes
|
||||
expectStatusWith "commit" committed 200
|
||||
check (at "applied" (jsonBody committed) `shouldBe` A.Number 2)
|
||||
duplicate <- editRequest hs "/api/edit/commit" "atomic" changes
|
||||
expectStatusWith "duplicate commit" duplicate 200
|
||||
check (jsonBody duplicate `shouldBe` jsonBody committed)
|
||||
changed <- editRequest hs "/api/edit/commit" "atomic" [editInsert "edit_atomic_a" 9]
|
||||
expectStatusWith "changed duplicate" changed 409
|
||||
rowsA <- testSql hs "SELECT * FROM edit_atomic_a"
|
||||
rowsB <- testSql hs "SELECT * FROM edit_atomic_b"
|
||||
check (asInt (at "rowCount" (jsonBody rowsA)) `shouldBe` 1)
|
||||
check (asInt (at "rowCount" (jsonBody rowsB)) `shouldBe` 1)
|
||||
it "rolls back a mid-batch constraint error to the savepoint and permits correction" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
made <- testSql hs "CREATE TABLE edit_constraints (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create table" made 200
|
||||
begun <- editRequest hs "/api/edit/begin" "constraints" []
|
||||
expectStatusWith "begin" begun 200
|
||||
failed <- editRequest hs "/api/edit/commit" "constraints" [editInsert "edit_constraints" 1, editInsert "edit_constraints" 1]
|
||||
expectStatusWith "constraint failure" failed 400
|
||||
check (at "state" (jsonBody failed) `shouldBe` A.String "active")
|
||||
check (at "applied" (jsonBody failed) `shouldBe` A.Number 0)
|
||||
visible <- testSql hs "SELECT * FROM edit_constraints"
|
||||
check (asInt (at "rowCount" (jsonBody visible)) `shouldBe` 0)
|
||||
snapshot <- getAs (("X-ChuSQL-Edit", "constraints") : withDb testDatabaseName hs) "/api/tables/edit_constraints/rows"
|
||||
expectStatusWith "snapshot after failure" snapshot 200
|
||||
check (asInt (at "total" (jsonBody snapshot)) `shouldBe` 0)
|
||||
corrected <- editRequest hs "/api/edit/commit" "constraints" [editInsert "edit_constraints" 2]
|
||||
expectStatusWith "corrected replay" corrected 200
|
||||
visibleAgain <- testSql hs "SELECT id FROM edit_constraints"
|
||||
check (firstRow visibleAgain `shouldBe` [A.Number 2])
|
||||
it "preserves the initial snapshot and rejects a conflicting external update" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
made <- testSql hs "CREATE TABLE edit_conflicts (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create table" made 200
|
||||
seeded <- testSql hs "INSERT INTO edit_conflicts (id, value) VALUES (1, 10)"
|
||||
expectStatusWith "seed row" seeded 200
|
||||
begun <- editRequest hs "/api/edit/begin" "conflicts" []
|
||||
expectStatusWith "begin" begun 200
|
||||
observer <- adminHeaders
|
||||
updated <- testSql observer "UPDATE edit_conflicts SET value = 20 WHERE id = 1"
|
||||
expectStatusWith "concurrent update" updated 200
|
||||
snapshot <- getAs (("X-ChuSQL-Edit", "conflicts") : withDb testDatabaseName hs) "/api/tables/edit_conflicts/rows"
|
||||
check (firstRow snapshot `shouldBe` [A.Number 1, A.Number 10])
|
||||
let change = object ["op" .= ("update" :: Text), "table" .= ("edit_conflicts" :: Text),
|
||||
"pk" .= (1 :: Int), "set" .= object ["value" .= (30 :: Int)]]
|
||||
failed <- editRequest hs "/api/edit/commit" "conflicts" [change]
|
||||
expectStatusWith "serialization conflict" failed 409
|
||||
check (at "reloadRequired" (jsonBody failed) `shouldBe` A.Bool True)
|
||||
live <- testSql observer "SELECT value FROM edit_conflicts"
|
||||
check (firstRow live `shouldBe` [A.Number 20])
|
||||
retried <- editRequest hs "/api/edit/commit" "conflicts" [change]
|
||||
expectStatusWith "stale retry" retried 409
|
||||
reopened <- editRequest hs "/api/edit/begin" "conflicts" []
|
||||
expectStatusWith "stale begin" reopened 409
|
||||
it "binds editing to one page and database and makes rollback idempotent" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
begun <- editRequest hs "/api/edit/begin" "owner" []
|
||||
expectStatusWith "begin" begun 200
|
||||
repeated <- editRequest hs "/api/edit/begin" "owner" []
|
||||
expectStatusWith "repeated begin" repeated 200
|
||||
otherPage <- editRequest hs "/api/edit/begin" "intruder" []
|
||||
expectStatusWith "another page" otherPage 409
|
||||
otherDatabase <- postAs hs "/api/edit/commit" (encode (object
|
||||
["id" .= ("owner" :: Text), "database" .= ("system" :: Text), "changes" .= ([] :: [A.Value])]))
|
||||
expectStatusWith "another database" otherDatabase 409
|
||||
forged <- getAs (("X-ChuSQL-Edit", "intruder") : withDb testDatabaseName hs) "/api/tables"
|
||||
expectStatusWith "another page snapshot" forged 409
|
||||
rolled <- editRequest hs "/api/edit/rollback" "owner" []
|
||||
expectStatusWith "rollback" rolled 200
|
||||
duplicate <- editRequest hs "/api/edit/rollback" "owner" []
|
||||
expectStatusWith "repeated rollback" duplicate 200
|
||||
stale <- editRequest hs "/api/edit/begin" "owner" []
|
||||
expectStatusWith "ended identity" stale 409
|
||||
it "keeps SQL-console transactions separate from grid editing" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
made <- testSql hs "CREATE TABLE edit_console (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create table" made 200
|
||||
consoleBegin <- testSql hs "BEGIN"
|
||||
expectStatusWith "console begin" consoleBegin 200
|
||||
consoleWrite <- testSql hs "INSERT INTO edit_console (id, value) VALUES (1, 1)"
|
||||
expectStatusWith "console write" consoleWrite 200
|
||||
begun <- editRequest hs "/api/edit/begin" "console" []
|
||||
expectStatusWith "editor begin" begun 200
|
||||
committed <- editRequest hs "/api/edit/commit" "console" [editInsert "edit_console" 2]
|
||||
expectStatusWith "editor commit" committed 200
|
||||
consoleRollback <- testSql hs "ROLLBACK"
|
||||
expectStatusWith "console rollback" consoleRollback 200
|
||||
live <- testSql hs "SELECT id FROM edit_console"
|
||||
check (firstRow live `shouldBe` [A.Number 2])
|
||||
it "drops the editing connection on logout and rejects the old transaction after login" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
begun <- editRequest hs "/api/edit/begin" "logout" []
|
||||
expectStatusWith "begin" begun 200
|
||||
gone <- postAs hs "/api/logout" "{}"
|
||||
expectStatusWith "logout" gone 204
|
||||
rejected <- editRequest hs "/api/edit/commit" "logout" []
|
||||
expectStatusWith "old cookie" rejected 401
|
||||
newHs <- adminHeaders
|
||||
stale <- editRequest newHs "/api/edit/commit" "logout" []
|
||||
expectStatusWith "old transaction on new connection" stale 409
|
||||
it "rejects system identity transactions" $ do
|
||||
hs <- adminHeaders
|
||||
res <- postAs hs "/api/edit/begin" (encode (object ["id" .= ("identities" :: Text), "database" .= ("system" :: Text)]))
|
||||
expectStatusWith "identity transaction" res 400
|
||||
it "invalidates editing when the account loses its permission" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
made <- testSql hs "CREATE TABLE edit_permissions (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create table" made 200
|
||||
ensureAccount hs "edit_user" "Editor-Secret-1234"
|
||||
granted <- testSql hs "GRANT SELECT, INSERT ON edit_permissions TO edit_user"
|
||||
expectStatusWith "grant editing" granted 200
|
||||
editor <- ordinaryHeaders "edit_user" "Editor-Secret-1234"
|
||||
begun <- editRequest editor "/api/edit/begin" "permissions" []
|
||||
expectStatusWith "begin" begun 200
|
||||
revoked <- testSql hs "REVOKE INSERT ON edit_permissions FROM edit_user"
|
||||
expectStatusWith "revoke insert" revoked 200
|
||||
rejected <- editRequest editor "/api/edit/commit" "permissions" [editInsert "edit_permissions" 1]
|
||||
expectStatusWith "invalidated editor" rejected 401
|
||||
live <- testSql hs "SELECT * FROM edit_permissions"
|
||||
check (asInt (at "rowCount" (jsonBody live)) `shouldBe` 0)
|
||||
it "restricts an SQL transaction to its owning console and blocks unrelated REST writes" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
let owner = ("X-ChuSQL-Console", "page:console-a") : hs
|
||||
intruder = ("X-ChuSQL-Console", "page:console-b") : hs
|
||||
begun <- testSql owner "/* owner */ BEGIN"
|
||||
expectStatusWith "console begin" begun 200
|
||||
rejected <- testSql intruder "ROLLBACK"
|
||||
expectStatusWith "other console rollback" rejected 409
|
||||
rest <- postAs (withDb testDatabaseName hs) "/api/tables" (encode (object
|
||||
["name" .= ("edit_rest_intruder" :: Text), "columns" .= [column "id" "int"]]))
|
||||
expectStatusWith "REST write inside console transaction" rest 409
|
||||
closed <- postAs hs "/api/query/rollback" (encode (object ["console" .= ("page:console-a" :: Text)]))
|
||||
expectStatusWith "close owning console" closed 204
|
||||
next <- testSql intruder "SELECT 1"
|
||||
expectStatusWith "other console after rollback" next 200
|
||||
it "cleans the editing snapshot when its web session expires" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
begun <- editRequest hs "/api/edit/begin" "expires" []
|
||||
expectStatusWith "begin" begun 200
|
||||
let token = fromMaybe "" (lookup "Cookie" hs >>= parseCookieHeader sessionCookieName . TE.decodeUtf8)
|
||||
stored <- liftIO (lookupPayload (aeSessions env) token)
|
||||
app <- getState
|
||||
res <- liftIO $ bracket
|
||||
(setPayloadPolicy (aeSessions env) (SessionPolicy (-1) 86400))
|
||||
(const (setPayloadPolicy (aeSessions env) defaultSessionPolicy))
|
||||
(const (editFromApp app hs "expires" []))
|
||||
expectStatusWith "expired commit" res 401
|
||||
case stored of
|
||||
Nothing -> check (expectationFailure "missing live web session")
|
||||
Just (_, ws) -> check (readIORef (wsEdit ws) >>= (`shouldBe` Nothing))
|
||||
it "serializes duplicate HTTP commits and applies the edit set once" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
made <- testSql hs "CREATE TABLE edit_double_click (id int PRIMARY KEY, value int)"
|
||||
expectStatusWith "create table" made 200
|
||||
begun <- editRequest hs "/api/edit/begin" "double-click" []
|
||||
expectStatusWith "begin" begun 200
|
||||
app <- getState
|
||||
results <- liftIO $ do
|
||||
first <- newEmptyMVar
|
||||
second <- newEmptyMVar
|
||||
_ <- forkFinally (editFromApp app hs "double-click" [editInsert "edit_double_click" 1]) (putMVar first)
|
||||
_ <- forkFinally (editFromApp app hs "double-click" [editInsert "edit_double_click" 1]) (putMVar second)
|
||||
mapM (either throwIO pure) =<< sequence [takeMVar first, takeMVar second]
|
||||
mapM_ (\res -> expectStatusWith "duplicate concurrent commit" res 200) results
|
||||
live <- testSql hs "SELECT * FROM edit_double_click"
|
||||
check (asInt (at "rowCount" (jsonBody live)) `shouldBe` 1)
|
||||
|
||||
-- | 从独立请求上下文提交编辑事务。
|
||||
editFromApp :: Wai.Application -> [Header] -> Text -> [A.Value] -> IO WT.SResponse
|
||||
editFromApp app hs owner changes = WT.runSession send app
|
||||
where
|
||||
-- | 构造带 Cookie 的原子提交请求。
|
||||
send = WT.srequest (WT.SRequest
|
||||
(WT.setPath WT.defaultRequest {Wai.requestMethod = methodPost, Wai.requestHeaders = jsonHeaders hs} "/api/edit/commit")
|
||||
(encode (object ["id" .= owner, "database" .= testDatabaseName, "changes" .= changes])))
|
||||
|
||||
-- | IDE 设置用例
|
||||
uiSettingsSpec :: AppEnv -> Spec
|
||||
uiSettingsSpec env = describe "IDE settings" $ do
|
||||
@@ -1000,7 +1287,25 @@ main = do
|
||||
limitedEnv <- liveEnv limitedWorld staticDir 1
|
||||
hspec (spec staticDir env limitedEnv)
|
||||
|
||||
-- | 汇总挂载全部用例组
|
||||
-- | 执行前端事务状态回归。
|
||||
frontendSpec :: Spec
|
||||
frontendSpec = describe "browser transaction state" $ do
|
||||
it "passes the Node runtime value suite" $ do
|
||||
node <- findExecutable "node"
|
||||
case node of
|
||||
Nothing -> expectationFailure "Node.js is required for runtime value tests"
|
||||
Just executable -> do
|
||||
(exitCode, output, errors) <- readProcessWithExitCode executable ["test/runtime-values.mjs"] ""
|
||||
when (exitCode /= ExitSuccess) (expectationFailure (output ++ errors))
|
||||
it "passes the Node frontend transaction suite" $ do
|
||||
node <- findExecutable "node"
|
||||
case node of
|
||||
Nothing -> expectationFailure "Node.js is required for frontend transaction tests"
|
||||
Just executable -> do
|
||||
(exitCode, output, errors) <- readProcessWithExitCode executable ["--test", "--test-reporter=tap", "test/transactions.mjs"] ""
|
||||
when (exitCode /= ExitSuccess) (expectationFailure (output ++ errors))
|
||||
|
||||
-- | 汇总挂载全部用例组。
|
||||
spec :: FilePath -> AppEnv -> AppEnv -> Spec
|
||||
spec staticDir env limitedEnv = do
|
||||
unitSpec
|
||||
@@ -1013,6 +1318,8 @@ spec staticDir env limitedEnv = do
|
||||
accountSpec env
|
||||
roleSpec env
|
||||
querySpec env
|
||||
editSpec env
|
||||
frontendSpec
|
||||
uiSettingsSpec env
|
||||
e2eSpec env
|
||||
rateLimitSpec limitedEnv
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// 检查前端复合值显示、SQL 生成和编码错误。
|
||||
|
||||
const source = await readFile(new URL('../static/core.js', import.meta.url), 'utf8');
|
||||
const { cellText, sqlLiteral, typeLabel, editText } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
||||
const typeInt = [1, []];
|
||||
const typeString = [4, []];
|
||||
const nested = { __chusql_runtime_v1: [1, [5, [[6, [typeInt]]]], [[], [42]]] };
|
||||
assert.equal(cellText(nested, 'NULL'), '[Nothing, Just (42)]');
|
||||
assert.equal(sqlLiteral(nested), 'LIST<Maybe Int>(MAYBE<Int>(), MAYBE<Int>(42))');
|
||||
assert.equal(sqlLiteral({ __chusql_runtime_v1: [1, [7, [typeInt, typeString]], [1, "a'b"]] }), "TUPLE<Int, String>(1, 'a''b')");
|
||||
assert.equal(sqlLiteral({ __chusql_runtime_v1: [1, [5, [[2, []]]], [1]] }), 'LIST<Double>(1.0)');
|
||||
assert.throws(() => cellText({ __chusql_runtime_v1: [2, typeInt, 1] }, 'NULL'), /encoding/);
|
||||
assert.throws(() => sqlLiteral({ __chusql_runtime_v1: [1, [7, [typeInt]], [1, 2]] }), /mismatch/);
|
||||
assert.equal(typeLabel('runtime([1,[5,[[1,[]]]]])'), '[Int]');
|
||||
assert.equal(editText({ __chusql_runtime_v1: [1, typeString, 'abc'] }), 'abc');
|
||||
assert.throws(() => sqlLiteral({ __chusql_runtime_v1: [1, typeInt, 1.5] }), /integer/);
|
||||
console.log('runtime values: 9 assertions passed');
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { beforeEach, test } from 'node:test';
|
||||
import * as api from '../static/api.js';
|
||||
import { createEmptyChangeSet } from '../static/core.js';
|
||||
|
||||
// 验证页面编辑状态、原子请求、断线重试与关闭清理。
|
||||
|
||||
const listeners = new Map();
|
||||
globalThis.window = { addEventListener: (name, callback) => listeners.set(name, callback), dispatchEvent: () => {} };
|
||||
globalThis.document = { getElementById: (id) => id === 'root' ? { addEventListener() {} } : null };
|
||||
let source = await readFile(new URL('../static/app.js', import.meta.url), 'utf8');
|
||||
source = source.replaceAll("'./api.js'", JSON.stringify(new URL('../static/api.js', import.meta.url).href))
|
||||
.replaceAll("'./core.js'", JSON.stringify(new URL('../static/core.js', import.meta.url).href))
|
||||
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql };');
|
||||
const app = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
||||
let calls;
|
||||
let commitResponse;
|
||||
|
||||
// 生成 JSON HTTP 响应。
|
||||
function response(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// 重置页面与模拟服务端。
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
commitResponse = () => response({ ok: true, applied: 1, state: 'committed' });
|
||||
api.forgetEdit();
|
||||
Object.assign(app.state, { database: 'test', databases: ['test'], tabs: [], activeId: undefined,
|
||||
view: 'workbench', session: { user: 'admin' }, edit: { id: 'page', database: 'test' },
|
||||
editBusy: false, editStarting: null, editAttempt: null, editNeedsReload: false, editUncertain: null,
|
||||
changes: createEmptyChangeSet(), messages: [] });
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init, body: init.body ? JSON.parse(init.body) : undefined });
|
||||
if (url === '/api/edit/commit') return commitResponse();
|
||||
if (url === '/api/databases') return response(['test']);
|
||||
if (url === '/api/tables') return response([]);
|
||||
return response({ state: 'active' });
|
||||
};
|
||||
});
|
||||
|
||||
// 检查整批修改只发送一次提交请求。
|
||||
test('all table edits use one atomic request and clear only after success', async () => {
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
||||
app.stageUpdate('second', 'test', 2, 'value', 20, 30);
|
||||
await app.commitAll();
|
||||
const committed = calls.filter((call) => call.url === '/api/edit/commit');
|
||||
assert.equal(committed.length, 1);
|
||||
assert.equal(committed[0].body.changes.length, 2);
|
||||
assert.equal(app.state.changes.updates.size, 0);
|
||||
assert.equal(app.state.edit, null);
|
||||
assert.equal(calls.some((call) => call.init.method === 'PATCH'), false);
|
||||
});
|
||||
|
||||
// 检查失败保留可修正的完整编辑集。
|
||||
test('constraint failure retains all edits and permits local undo before replay', async () => {
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
||||
app.stageUpdate('second', 'test', 2, 'value', 20, 30);
|
||||
commitResponse = () => response({ ok: false, applied: 0, state: 'active', message: 'constraint', reloadRequired: false }, 400);
|
||||
await app.commitAll();
|
||||
assert.equal(app.state.changes.updates.size, 2);
|
||||
assert.equal(app.state.editNeedsReload, false);
|
||||
app.removeStaged([...app.state.changes.updates.keys()][0]);
|
||||
assert.equal(app.state.changes.updates.size, 1);
|
||||
assert.equal(calls.some((call) => call.init.method === 'PATCH'), false);
|
||||
});
|
||||
|
||||
// 检查冲突禁止再次提交直到回滚。
|
||||
test('serialization conflict freezes edits and requires rollback and reload', async () => {
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
||||
commitResponse = () => response({ ok: false, state: 'rolled_back', reloadRequired: true, message: 'conflict' }, 409);
|
||||
await app.commitAll();
|
||||
await app.commitAll();
|
||||
assert.equal(calls.filter((call) => call.url === '/api/edit/commit').length, 1);
|
||||
assert.equal(app.state.changes.updates.size, 1);
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 40);
|
||||
assert.equal([...app.state.changes.updates.values()][0].newValue, 20);
|
||||
await app.rollbackAll();
|
||||
assert.equal(app.state.changes.updates.size, 0);
|
||||
assert.equal(app.state.editNeedsReload, false);
|
||||
assert.equal(calls.some((call) => call.url === '/api/edit/rollback'), true);
|
||||
});
|
||||
|
||||
// 检查断线后冻结载荷并使用同一提交标识。
|
||||
test('an uncertain response retries the same payload without allowing edits', async () => {
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
||||
commitResponse = () => { throw new Error('connection lost'); };
|
||||
await app.commitAll();
|
||||
assert.ok(app.state.editUncertain);
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 99);
|
||||
commitResponse = () => response({ ok: true, applied: 1, state: 'committed' });
|
||||
await app.commitAll();
|
||||
const committed = calls.filter((call) => call.url === '/api/edit/commit');
|
||||
assert.deepEqual(committed[0].body, committed[1].body);
|
||||
assert.equal(app.state.editUncertain, null);
|
||||
});
|
||||
|
||||
// 检查并发点击只有一个正在提交的请求。
|
||||
test('duplicate clicks cannot submit concurrently', async () => {
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
||||
await Promise.all([app.commitAll(), app.commitAll()]);
|
||||
assert.equal(calls.filter((call) => call.url === '/api/edit/commit').length, 1);
|
||||
});
|
||||
|
||||
// 检查快照读取带页面标识且普通 SQL 无标识。
|
||||
test('snapshot headers are attached only to table reads', async () => {
|
||||
await api.beginEdit('page', 'test');
|
||||
await api.listTables('test');
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return response({ columns: [], rows: [], rowCount: 0 });
|
||||
};
|
||||
await api.query('SELECT 1', 'test');
|
||||
assert.equal(calls.find((call) => call.url === '/api/tables').init.headers.get('X-ChuSQL-Edit'), 'page');
|
||||
assert.equal(calls.find((call) => call.url === '/api/query').init.headers.has('X-ChuSQL-Edit'), false);
|
||||
});
|
||||
|
||||
// 检查页面离开时发送回滚请求。
|
||||
test('page close sends a rollback beacon for its own transaction', async () => {
|
||||
let beacon;
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { sendBeacon: (url, body) => { beacon = { url, body }; } } });
|
||||
listeners.get('pagehide')();
|
||||
assert.equal(beacon.url, '/api/edit/rollback');
|
||||
assert.deepEqual(JSON.parse(await beacon.body.text()), { id: 'page', database: 'test' });
|
||||
});
|
||||
|
||||
// 检查 BEGIN 回包断线后仍使用同一标识。
|
||||
test('a lost BEGIN response keeps its identity for an idempotent retry', async () => {
|
||||
app.state.edit = null;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init, body: JSON.parse(init.body) });
|
||||
if (calls.length === 1) throw new Error('begin response lost');
|
||||
return response({ state: 'active' });
|
||||
};
|
||||
await assert.rejects(app.ensureEdit('test'));
|
||||
assert.ok(app.state.editAttempt);
|
||||
await app.ensureEdit('test');
|
||||
assert.deepEqual(calls[0].body, calls[1].body);
|
||||
assert.equal(app.state.edit.id, calls[0].body.id);
|
||||
});
|
||||
|
||||
// 检查每个 SQL 标签使用独立所有者标识。
|
||||
test('console transaction cleanup only applies to its owning tab', async () => {
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init, body: JSON.parse(init.body) });
|
||||
return response({ columns: [], rows: [], rowCount: 0, transaction: true });
|
||||
};
|
||||
await api.query('BEGIN', 'test', 'first');
|
||||
const owner = calls[0].init.headers.get('X-ChuSQL-Console');
|
||||
assert.ok(owner.endsWith(':first'));
|
||||
await api.rollbackConsole('second');
|
||||
assert.equal(calls.length, 1);
|
||||
await api.rollbackConsole('first');
|
||||
assert.equal(calls[1].url, '/api/query/rollback');
|
||||
assert.equal(calls[1].body.console, owner);
|
||||
});
|
||||
|
||||
// 检查 SQL 回滚等待期间不能继续编辑旧快照。
|
||||
test('edits are locked while SQL releases the previous snapshot', async () => {
|
||||
let release;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
if (url === '/api/edit/rollback') return new Promise((resolve) => { release = () => resolve(response({ state: 'rolled_back' })); });
|
||||
return response({ columns: ['value'], rows: [[1]], rowCount: 1 });
|
||||
};
|
||||
const job = app.runSql({ id: 'query', database: 'test', sql: 'SELECT 1' });
|
||||
assert.equal(app.state.editBusy, true);
|
||||
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
||||
assert.equal(app.state.changes.updates.size, 0);
|
||||
release();
|
||||
await job;
|
||||
assert.equal(app.state.editBusy, false);
|
||||
assert.equal(app.state.edit, null);
|
||||
});
|
||||
Reference in new issue
Block a user