v26.10.1 正式版

修复部分问题,优化性能,进一步推进类型系统
This commit is contained in:
LIm1tZz7 committed 2026-10-05 22:22:23 +08:00
1 parent 48eec484b7
commit 8ce4239452
89 files changed
+5808 -884

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ library
, aeson
, bytestring
, chusql-core-model
, chusql-core-engine
, chusql-interface
, containers
, directory
+304 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+60
View File
@@ -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();
+2 -1
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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');
+175
View File
@@ -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);
});