w-web-perm 1.1.11 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -117
- package/dist/css/app.f09c7433.css +1 -0
- package/dist/index.tmp +1 -1
- package/dist/js/app-legacy.8af87052.js +1 -0
- package/dist/js/app.8af87052.js +1 -0
- package/dist/js/{chunk-vendors.3e9e3ef1.js → chunk-vendors-legacy.ece5a361.js} +1 -1
- package/dist/js/{chunk-vendors-legacy.3e9e3ef1.js → chunk-vendors.ece5a361.js} +1 -1
- package/dist/w-web-perm.umd.js +2 -2
- package/dist/w-web-perm.umd.js.map +1 -1
- package/docs/WWebPerm.html +84 -4
- package/docs/global.html +24747 -832
- package/docs/index.html +2 -2
- package/docs/server_WWebPerm.mjs.html +245 -32
- package/docs/server_cascadeWrite.mjs.html +225 -0
- package/docs/server_dataChannel.mjs.html +2 -2
- package/docs/server_keyCheck.mjs.html +353 -0
- package/docs/server_lockSave.mjs.html +2 -2
- package/docs/server_maskLog.mjs.html +2 -2
- package/docs/server_procCore.mjs.html +180 -41
- package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
- package/docs/server_syncKeep.mjs.html +311 -0
- package/docs/server_tools_authRefsAudit.mjs.html +247 -0
- package/docs/server_tools_authRefsClean.mjs.html +335 -0
- package/docs/server_tools_fromTools.mjs.html +797 -0
- package/docs/server_tools_isMain.mjs.html +103 -0
- package/docs/server_tools_nameDupReport.mjs.html +280 -0
- package/docs/server_validateRows.mjs.html +15 -4
- package/docs/server_wrapInjected.mjs.html +3 -3
- package/docs/src_fetchJson.mjs.html +2 -2
- package/docs/src_js_cvKpToArr.mjs.html +125 -0
- package/docs/src_js_cvKpToObj.mjs.html +210 -0
- package/docs/src_js_draftSync.mjs.html +142 -0
- package/docs/src_js_eachOwn.mjs.html +3 -3
- package/docs/src_js_editorRefRows.mjs.html +356 -0
- package/docs/src_js_externalRows.mjs.html +338 -0
- package/docs/src_js_externalUi.mjs.html +230 -0
- package/docs/src_js_findRefRows.mjs.html +107 -0
- package/docs/src_js_findTouchedDup.mjs.html +150 -0
- package/docs/src_js_fmtLossyMsg.mjs.html +95 -0
- package/docs/src_js_fromAudit.mjs.html +489 -0
- package/docs/src_js_getAddImpact.mjs.html +269 -0
- package/docs/src_js_getAuthWarn.mjs.html +463 -0
- package/docs/src_js_getCascadePreview.mjs.html +258 -0
- package/docs/src_js_getDelRefCount.mjs.html +210 -0
- package/docs/src_js_getEffMode.mjs.html +92 -0
- package/docs/src_js_getKpLossy.mjs.html +155 -0
- package/docs/src_js_getNameErrs.mjs.html +266 -0
- package/docs/src_js_getOwn.mjs.html +3 -3
- package/docs/src_js_getPropKey.mjs.html +109 -0
- package/docs/src_js_getRuleEnable.mjs.html +2 -2
- package/docs/src_js_isAuthMode.mjs.html +92 -0
- package/docs/src_js_joinNames.mjs.html +96 -0
- package/docs/src_js_legendFit.mjs.html +2 -2
- package/docs/src_js_pairRowsToSnapshot.mjs.html +156 -0
- package/docs/src_js_refAudit.mjs.html +359 -0
- package/docs/src_js_refCascade.mjs.html +572 -0
- package/docs/src_js_relDialogSend.mjs.html +199 -0
- package/docs/src_js_rowDigest.mjs.html +265 -0
- package/docs/src_plugins_dataReloadGuard.mjs.html +109 -0
- package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
- package/docs/src_plugins_submitGuard.mjs.html +2 -2
- package/g_authRefsClean.mjs +79 -0
- package/g_fixFromType.mjs +92 -0
- package/g_initialData.mjs +3 -3
- package/g_purgeFrom.mjs +127 -0
- package/package.json +1 -1
- package/server/WWebPerm.mjs +243 -30
- package/server/cascadeWrite.mjs +153 -0
- package/server/keyCheck.mjs +281 -0
- package/server/procCore.mjs +178 -39
- package/server/procLang.mjs +271 -0
- package/server/syncKeep.mjs +239 -0
- package/server/tools/authRefsAudit.mjs +175 -0
- package/server/tools/authRefsClean.mjs +263 -0
- package/server/tools/fromTools.mjs +725 -0
- package/server/tools/isMain.mjs +31 -0
- package/server/tools/nameDupReport.mjs +208 -0
- package/server/validateRows.mjs +13 -2
- package/server/wrapInjected.mjs +1 -1
- package/src/components/LayoutContentGrups.vue +399 -77
- package/src/components/LayoutContentPemis.vue +400 -78
- package/src/components/LayoutContentTargets.vue +261 -25
- package/src/components/LayoutContentUsers.vue +336 -98
- package/src/components/VeCgrups.vue +119 -115
- package/src/components/VeCpemis.vue +117 -115
- package/src/components/VeCrules.vue +18 -2
- package/src/components/VeGrupBlngUsers.vue +240 -73
- package/src/components/VePemiBlngGrups.vue +247 -83
- package/src/js/cvKpToArr.mjs +53 -0
- package/src/js/cvKpToObj.mjs +138 -0
- package/src/js/draftSync.mjs +70 -0
- package/src/js/eachOwn.mjs +1 -1
- package/src/js/editorRefRows.mjs +284 -0
- package/src/js/externalRows.mjs +266 -0
- package/src/js/externalUi.mjs +158 -0
- package/src/js/findRefRows.mjs +35 -0
- package/src/js/findTouchedDup.mjs +78 -0
- package/src/js/fmtLossyMsg.mjs +23 -0
- package/src/js/fromAudit.mjs +417 -0
- package/src/js/getAddImpact.mjs +197 -0
- package/src/js/getAuthWarn.mjs +391 -0
- package/src/js/getCascadePreview.mjs +186 -0
- package/src/js/getDelRefCount.mjs +138 -0
- package/src/js/getEffMode.mjs +20 -0
- package/src/js/getKpLossy.mjs +83 -0
- package/src/js/getNameErrs.mjs +194 -0
- package/src/js/getOwn.mjs +1 -1
- package/src/js/getPropKey.mjs +37 -0
- package/src/js/isAuthMode.mjs +20 -0
- package/src/js/joinNames.mjs +24 -0
- package/src/js/pairRowsToSnapshot.mjs +84 -0
- package/src/js/refAudit.mjs +287 -0
- package/src/js/refCascade.mjs +500 -0
- package/src/js/relDialogSend.mjs +127 -0
- package/src/js/rowDigest.mjs +193 -0
- package/src/plugins/dataReloadGuard.mjs +37 -0
- package/src/plugins/mShare.mjs +147 -174
- package/src/plugins/mUI.mjs +3 -8
- package/src/schema/tables/users.mjs +152 -152
- package/dist/css/app.6918ec45.css +0 -1
- package/dist/js/app-legacy.963e800c.js +0 -1
- package/dist/js/app.963e800c.js +0 -1
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>server/tools/fromTools.mjs - Documentation</title>
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
<script src="scripts/prettify/prettify.js"></script>
|
|
10
|
+
<script src="scripts/prettify/lang-css.js"></script>
|
|
11
|
+
<!--[if lt IE 9]>
|
|
12
|
+
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
|
13
|
+
<![endif]-->
|
|
14
|
+
<link type="text/css" rel="stylesheet" href="styles/prettify.css">
|
|
15
|
+
<link type="text/css" rel="stylesheet" href="styles/jsdoc.css">
|
|
16
|
+
<script src="scripts/nav.js" defer></script>
|
|
17
|
+
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
|
|
22
|
+
<input type="checkbox" id="nav-trigger" class="nav-trigger" />
|
|
23
|
+
<label for="nav-trigger" class="navicon-button x">
|
|
24
|
+
<div class="navicon"></div>
|
|
25
|
+
</label>
|
|
26
|
+
|
|
27
|
+
<label for="nav-trigger" class="overlay"></label>
|
|
28
|
+
|
|
29
|
+
<nav >
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
<h2><a href="index.html">Home</a></h2><h3>Classes</h3><ul><li><a href="WWebPerm.html">WWebPerm</a></li></ul><h3>Global</h3><ul><li><a href="global.html#applyPurgePlan">applyPurgePlan</a></li><li><a href="global.html#auditFrom">auditFrom</a></li><li><a href="global.html#auditRefs">auditRefs</a></li><li><a href="global.html#buildReport">buildReport</a></li><li><a href="global.html#buildRowsSend">buildRowsSend</a></li><li><a href="global.html#buildSyncRowsNew">buildSyncRowsNew</a></li><li><a href="global.html#checkBaseline">checkBaseline</a></li><li><a href="global.html#checkCascadePlan">checkCascadePlan</a></li><li><a href="global.html#checkDbNotInUse">checkDbNotInUse</a></li><li><a href="global.html#checkExternalWrite">checkExternalWrite</a></li><li><a href="global.html#checkMapKeyBatch">checkMapKeyBatch</a></li><li><a href="global.html#checkUsersWrite">checkUsersWrite</a></li><li><a href="global.html#cleanOutOf">cleanOutOf</a></li><li><a href="global.html#compareBaseline">compareBaseline</a></li><li><a href="global.html#countAndRefs">countAndRefs</a></li><li><a href="global.html#countExternalRows">countExternalRows</a></li><li><a href="global.html#countIdRenumber">countIdRenumber</a></li><li><a href="global.html#createAuthorizeExec">createAuthorizeExec</a></li><li><a href="global.html#createLegendMeasurer">createLegendMeasurer</a></li><li><a href="global.html#createLockSave">createLockSave</a></li><li><a href="global.html#cvKpToArr">cvKpToArr</a></li><li><a href="global.html#cvKpToObj">cvKpToObj</a></li><li><a href="global.html#decideLegendType">decideLegendType</a></li><li><a href="global.html#describeUpstream">describeUpstream</a></li><li><a href="global.html#diffRefTargets">diffRefTargets</a></li><li><a href="global.html#dropServerOnlyDiff">dropServerOnlyDiff</a></li><li><a href="global.html#eachOwn">eachOwn</a></li><li><a href="global.html#fetchJson">fetchJson</a></li><li><a href="global.html#fillIdlessIds">fillIdlessIds</a></li><li><a href="global.html#findDupIndex">findDupIndex</a></li><li><a href="global.html#findKeyOtherFrom">findKeyOtherFrom</a></li><li><a href="global.html#findRefRows">findRefRows</a></li><li><a href="global.html#findTouchedDup">findTouchedDup</a></li><li><a href="global.html#fmtLossyMsg">fmtLossyMsg</a></li><li><a href="global.html#fmtVars">fmtVars</a></li><li><a href="global.html#fmtWarn">fmtWarn</a></li><li><a href="global.html#formatFixReport">formatFixReport</a></li><li><a href="global.html#formatFromAudit">formatFromAudit</a></li><li><a href="global.html#formatPurgeReport">formatPurgeReport</a></li><li><a href="global.html#genLegend">genLegend</a></li><li><a href="global.html#getAddConfirmText">getAddConfirmText</a></li><li><a href="global.html#getAddImpact">getAddImpact</a></li><li><a href="global.html#getBannerLines">getBannerLines</a></li><li><a href="global.html#getBaselineDigest">getBaselineDigest</a></li><li><a href="global.html#getCascadePreview">getCascadePreview</a></li><li><a href="global.html#getChangedIdx">getChangedIdx</a></li><li><a href="global.html#getDelConfirmText">getDelConfirmText</a></li><li><a href="global.html#getDelImpact">getDelImpact</a></li><li><a href="global.html#getDelRefCount">getDelRefCount</a></li><li><a href="global.html#getDigestFields">getDigestFields</a></li><li><a href="global.html#getDupRowsInconsistent">getDupRowsInconsistent</a></li><li><a href="global.html#getEffMode">getEffMode</a></li><li><a href="global.html#getExternalEdits">getExternalEdits</a></li><li><a href="global.html#getItemWarnText">getItemWarnText</a></li><li><a href="global.html#getKpLossy">getKpLossy</a></li><li><a href="global.html#getLockFields">getLockFields</a></li><li><a href="global.html#getNameErrs">getNameErrs</a></li><li><a href="global.html#getOwn">getOwn</a></li><li><a href="global.html#getPropKey">getPropKey</a></li><li><a href="global.html#getRawModeMap">getRawModeMap</a></li><li><a href="global.html#getRefRowsExtra">getRefRowsExtra</a></li><li><a href="global.html#getRefState">getRefState</a></li><li><a href="global.html#getRefWarnText">getRefWarnText</a></li><li><a href="global.html#getRefWarns">getRefWarns</a></li><li><a href="global.html#getRowIdxs">getRowIdxs</a></li><li><a href="global.html#getRowState">getRowState</a></li><li><a href="global.html#getRuleEnable">getRuleEnable</a></li><li><a href="global.html#getRuleWarnText">getRuleWarnText</a></li><li><a href="global.html#getRuleWarns">getRuleWarns</a></li><li><a href="global.html#getSavePreview">getSavePreview</a></li><li><a href="global.html#getSyncWarnLines">getSyncWarnLines</a></li><li><a href="global.html#hasDupKey">hasDupKey</a></li><li><a href="global.html#hasExternalDiff">hasExternalDiff</a></li><li><a href="global.html#isAuthMode">isAuthMode</a></li><li><a href="global.html#isBaselineStale">isBaselineStale</a></li><li><a href="global.html#isBaselineValid">isBaselineValid</a></li><li><a href="global.html#isDraftStale">isDraftStale</a></li><li><a href="global.html#isExternalRow">isExternalRow</a></li><li><a href="global.html#isJsonLossy">isJsonLossy</a></li><li><a href="global.html#isMain">isMain</a></li><li><a href="global.html#joinList">joinList</a></li><li><a href="global.html#joinNames">joinNames</a></li><li><a href="global.html#keepAssignByEntity">keepAssignByEntity</a></li><li><a href="global.html#keepAssignOnMissing">keepAssignOnMissing</a></li><li><a href="global.html#makeBaseline">makeBaseline</a></li><li><a href="global.html#makeRowStates">makeRowStates</a></li><li><a href="global.html#mapKeyOf">mapKeyOf</a></li><li><a href="global.html#mapNameKp">mapNameKp</a></li><li><a href="global.html#markDataReload">markDataReload</a></li><li><a href="global.html#markHasKey">markHasKey</a></li><li><a href="global.html#maskQuery">maskQuery</a></li><li><a href="global.html#maskTok">maskTok</a></li><li><a href="global.html#maskUrl">maskUrl</a></li><li><a href="global.html#mergePickKeysOnly">mergePickKeysOnly</a></li><li><a href="global.html#pairRowsToSnapshot">pairRowsToSnapshot</a></li><li><a href="global.html#pickUniqueUser">pickUniqueUser</a></li><li><a href="global.html#planDraftSync">planDraftSync</a></li><li><a href="global.html#planFixFromType">planFixFromType</a></li><li><a href="global.html#planPurgeChain">planPurgeChain</a></li><li><a href="global.html#planPurgeFrom">planPurgeFrom</a></li><li><a href="global.html#planRefChanges">planRefChanges</a></li><li><a href="global.html#putRefEntry">putRefEntry</a></li><li><a href="global.html#putRowsToKp">putRowsToKp</a></li><li><a href="global.html#refsChanged">refsChanged</a></li><li><a href="global.html#resolveShellWorker">resolveShellWorker</a></li><li><a href="global.html#restoreExternalOrder">restoreExternalOrder</a></li><li><a href="global.html#restoreServerFields">restoreServerFields</a></li><li><a href="global.html#reuseDeletedIds">reuseDeletedIds</a></li><li><a href="global.html#rewriteRefs">rewriteRefs</a></li><li><a href="global.html#rowDigest">rowDigest</a></li><li><a href="global.html#runAuditFrom">runAuditFrom</a></li><li><a href="global.html#runClean">runClean</a></li><li><a href="global.html#runFixFromType">runFixFromType</a></li><li><a href="global.html#runPurgeFrom">runPurgeFrom</a></li><li><a href="global.html#splitCheckedRows">splitCheckedRows</a></li><li><a href="global.html#submitGuard">submitGuard</a></li><li><a href="global.html#tabWriteKey">tabWriteKey</a></li><li><a href="global.html#validateRowsBeforeWrite">validateRowsBeforeWrite</a></li><li><a href="global.html#withTabLocks">withTabLocks</a></li><li><a href="global.html#wrapInjected">wrapInjected</a></li><li><a href="global.html#wrapOperOrm">wrapOperOrm</a></li><li><a href="global.html#writeWithCascade">writeWithCascade</a></li></ul>
|
|
33
|
+
|
|
34
|
+
</nav>
|
|
35
|
+
|
|
36
|
+
<div id="main">
|
|
37
|
+
|
|
38
|
+
<h1 class="page-title">server/tools/fromTools.mjs</h1>
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
<section>
|
|
47
|
+
<article>
|
|
48
|
+
<pre class="prettyprint source linenums"><code>//來源欄 from 之離線維運工具之執行核心(run07 升版工具; F-23 外部列受保護之配套): 由專案根之 g_fixFromType.mjs/g_purgeFrom.mjs 呼叫(連專案資料庫);
|
|
49
|
+
//邏輯抽成可注入 woItems 之函數, 供單元測試以 ORM 替身與真 lmdb 臨時庫驗證(test/unit-fromAudit.test.mjs). 風格與安全機制同 server/tools/authRefsClean.mjs(runClean).
|
|
50
|
+
//純函數(稽核、規劃、清除計畫)在 src/js/fromAudit.mjs; 本檔負責讀寫、備份、驗證與文字報告.
|
|
51
|
+
//
|
|
52
|
+
//共通安全機制(apply 之順序, 任一步失敗即中止、其後步驟不執行; 備份失敗時零寫入):
|
|
53
|
+
// 1. 讀取四表並稽核/規劃; 無須處理者不備份、不寫入; 規劃被拒(如引用端含無法安全重新編碼之內容)者整體拒絕、零寫入
|
|
54
|
+
// 2. (a) 備份整個資料庫(getDb.backup(woItems), 落點為模組載入時之工作目錄之 backup-時間.json, 同 g_backupDb.mjs; 可由 backupDb 注入)
|
|
55
|
+
// 3. (b) 另匯出四表全部列為 json(backupPath; wx 建立, 不覆寫既有檔), 寫後讀回驗證筆數
|
|
56
|
+
// 4. 重新讀取四表, 與規劃時逐表逐列比對(稽核後被他人改動者中止, 免以過期資料覆寫)
|
|
57
|
+
// 5. 寫入(經 ORM; 引用端只改該欄位: save(…, { autoInsert: false }); 清除以 del): 逐筆結果不為成功者視為失敗並列出, 立即中止後續寫入
|
|
58
|
+
// 6. 重新讀取四表, 與「預期結果」逐位元比對(fix: 該些列 from 改為 ''、其餘不變; purge: 規劃套用後之預測狀態), 另做不變式檢查
|
|
59
|
+
//冪等: 失敗後修正原因可原樣重跑同一指令(已完成之部分於下次稽核不再出現), 不需手動還原; 要還原則依備份檔(資料庫備份 backup-時間.json 可由 w-serv-orm 之 recover 於清空資料表後還原; 四表全部列備份檔為 json).
|
|
60
|
+
//備份檔含 email 等個資, 請妥善保管、勿外傳、勿納入版控.
|
|
61
|
+
//
|
|
62
|
+
//輸出(stdout)可列主鍵 id 與群組/權限/標的名稱, 不列使用者姓名與 email(同 authRefsAudit 之慣例); 不寫 srLog(離線工具).
|
|
63
|
+
|
|
64
|
+
import fs from 'fs'
|
|
65
|
+
import path from 'path'
|
|
66
|
+
import { isDeepStrictEqual } from 'util'
|
|
67
|
+
import getDb from 'w-serv-orm/src/getDb.mjs'
|
|
68
|
+
import now2strp from 'wsemi/src/now2strp.mjs'
|
|
69
|
+
import auditRefs from '../../src/js/refAudit.mjs'
|
|
70
|
+
import { TABLE_ORDER, auditFrom, planFixFromType, planPurgeChain } from '../../src/js/fromAudit.mjs'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
const TABLES = ['users', 'grups', 'pemis', 'targets'] //讀取順序同 authRefsClean.mjs
|
|
74
|
+
const MAX_LIST = 50 //報告每表最多列出之被清除列數
|
|
75
|
+
const MAX_IDS = 10 //報告每表最多列出之待改列 id 數
|
|
76
|
+
|
|
77
|
+
const KIND_TXT = {
|
|
78
|
+
empty: '\'\' (後台列)',
|
|
79
|
+
missing: '缺欄(視同 \'\', 後台列)',
|
|
80
|
+
null: 'null(非字串, 後台列)',
|
|
81
|
+
number: '數字(非字串, 後台列)',
|
|
82
|
+
boolean: '布林(非字串, 後台列)',
|
|
83
|
+
object: '物件(非字串, 後台列)',
|
|
84
|
+
array: '陣列(非字串, 後台列)',
|
|
85
|
+
other: '其他型別(非字串, 後台列)',
|
|
86
|
+
notObject: '列本身非物件',
|
|
87
|
+
}
|
|
88
|
+
const ERR_TXT = {
|
|
89
|
+
invalidTable: '表名須為 targets/pemis/grups/users 之一',
|
|
90
|
+
invalidFrom: 'from 須為非空字串',
|
|
91
|
+
purgeRowNoId: '被清除列缺非空字串主鍵而無從刪除(不做部分清除)',
|
|
92
|
+
renameCascadeUnsupported: '引用端授權字串含無法安全重新編碼之內容(JSON5 Infinity/NaN 數值), 清理引用會改指別的規則, 須先人工處理該引用端列',
|
|
93
|
+
renameCascadeConflict: '引用端授權字串之改寫發生名稱相撞, 無法自動清理',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
let arr = (v) => (Array.isArray(v) ? v : [])
|
|
98
|
+
let j = (v) => JSON.stringify(v)
|
|
99
|
+
let msgOf = (err) => (err && err.message ? err.message : String(err))
|
|
100
|
+
let strCmp = (a, b) => (a < b ? -1 : (a > b ? 1 : 0))
|
|
101
|
+
let sortById = (rows) => arr(rows).slice().sort((a, b) => strCmp(String(a !== null && typeof a === 'object' ? a.id : ''), String(b !== null && typeof b === 'object' ? b.id : '')))
|
|
102
|
+
let rowId = (r) => (r !== null && typeof r === 'object' ? r.id : undefined)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
//readTables: 讀取四表全部列
|
|
106
|
+
async function readTables(woItems) {
|
|
107
|
+
let rows = {}
|
|
108
|
+
for (let t of TABLES) {
|
|
109
|
+
let rs = await woItems[t].select()
|
|
110
|
+
if (!Array.isArray(rs)) {
|
|
111
|
+
throw new Error(`select ${t} did not return an array`)
|
|
112
|
+
}
|
|
113
|
+
rows[t] = rs
|
|
114
|
+
}
|
|
115
|
+
return rows
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
//diffTables: 兩份四表資料(依主鍵排序後逐列深度嚴格相等)有差異之表名陣列
|
|
120
|
+
let diffTables = (a, b) => TABLES.filter((t) => !isDeepStrictEqual(sortById(a[t]), sortById(b[t])))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
//checkWo: woItems 之表與函數檢查, 回傳錯誤訊息或 ''
|
|
124
|
+
let checkWo = (woItems, fns) => {
|
|
125
|
+
if (woItems === null || typeof woItems !== 'object') {
|
|
126
|
+
return 'invalid woItems'
|
|
127
|
+
}
|
|
128
|
+
for (let t of TABLES) {
|
|
129
|
+
for (let f of fns) {
|
|
130
|
+
if (!woItems[t] || typeof woItems[t][f] !== 'function') {
|
|
131
|
+
return `woItems.${t} lacks ${f}`
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
//saveRows: 逐表 save(只改指定欄位; autoInsert: false, 列已不存在時不得補出只有一欄之殘列), 逐筆結果非成功者回傳失敗清單
|
|
140
|
+
async function saveRows(wo, table, items) {
|
|
141
|
+
let failed = []
|
|
142
|
+
let rs = null
|
|
143
|
+
try {
|
|
144
|
+
rs = await wo.save(items, { autoInsert: false })
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return { written: [], failed: items.map((x) => ({ table, id: x.id, err: msgOf(err) })) }
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(rs) || rs.length !== items.length) {
|
|
150
|
+
return { written: [], failed: items.map((x) => ({ table, id: x.id, err: 'unexpected save result' })) }
|
|
151
|
+
}
|
|
152
|
+
let written = []
|
|
153
|
+
items.forEach((x, k) => {
|
|
154
|
+
let r = rs[k]
|
|
155
|
+
if (r && r.ok === 1 && r.n !== 0 && !(r.nInserted > 0)) {
|
|
156
|
+
written.push({ table, id: x.id })
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
failed.push({ table, id: x.id, err: (r && r.err) ? String(r.err) : `save result ${j(r)}` })
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
return { written, failed }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
//delRows: 逐表 del, 逐筆結果須為 n=1/nDeleted=1/ok=1(列已不存在 n 為 0 視為失敗: 稽核後被改動)
|
|
167
|
+
async function delRows(wo, table, ids) {
|
|
168
|
+
let rs = null
|
|
169
|
+
try {
|
|
170
|
+
rs = await wo.del(ids.map((id) => ({ id })))
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return { written: [], failed: ids.map((id) => ({ table, id, err: msgOf(err) })) }
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(rs) || rs.length !== ids.length) {
|
|
176
|
+
return { written: [], failed: ids.map((id) => ({ table, id, err: 'unexpected del result' })) }
|
|
177
|
+
}
|
|
178
|
+
let written = []
|
|
179
|
+
let failed = []
|
|
180
|
+
ids.forEach((id, k) => {
|
|
181
|
+
let r = rs[k]
|
|
182
|
+
if (r && r.ok === 1 && r.n === 1 && r.nDeleted === 1) {
|
|
183
|
+
written.push({ table, id })
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
failed.push({ table, id, err: (r && r.err) ? String(r.err) : `del result ${j(r)}` })
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
return { written, failed }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 值是否無法經 JSON 往返而保真(備份為 JSON 檔, 不保真者無法依備份還原原列)
|
|
195
|
+
*
|
|
196
|
+
* 以「JSON 編碼再解碼須與原值嚴格深度相等」判定(run07 增量外審 N3): 比逐型別列舉完備——NaN/±Infinity/bigint/Date(被編成字串)/自有 undefined(被丟棄)/陣列內 undefined(被改 null)/Buffer(被改物件)皆不相等;
|
|
197
|
+
* 無法 JSON 編碼者(bigint、循環參照)亦視為不保真. 同時供 server/tools/authRefsClean.mjs 之 runClean 使用(run08 外審 sol #2)
|
|
198
|
+
*
|
|
199
|
+
* @param {*} v 輸入任意值(如四表全部列之陣列)
|
|
200
|
+
* @returns {Boolean} 回傳不保真為 true
|
|
201
|
+
*/
|
|
202
|
+
function isJsonLossy(v) {
|
|
203
|
+
try {
|
|
204
|
+
return !isDeepStrictEqual(JSON.parse(JSON.stringify(v)), v)
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
return true //bigint 等無法 JSON 編碼者
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
//backupBefore: 備份(a)整個資料庫 (b)四表全部列(wx); 成功回傳 null, 失敗回傳 { stage, err }
|
|
213
|
+
async function backupBefore(p) {
|
|
214
|
+
let { res, rows0, tool, meta, backupPath, backupDb, log } = p
|
|
215
|
+
|
|
216
|
+
//備份保真(外審 sol R11): 兩份備份皆為 JSON, NaN/±Infinity/bigint 等非 JSON 值會被存成 null 或失敗而無法還原原列, 筆數回驗不會發現;
|
|
217
|
+
//列內含此類值時於任何備份與寫入之前中止(零寫入), 須人工處理該列
|
|
218
|
+
res.stage = 'backupFidelity'
|
|
219
|
+
for (let t of TABLES) {
|
|
220
|
+
if (isJsonLossy(rows0[t])) {
|
|
221
|
+
return { stage: 'backupFidelity', err: new Error(`backup would be lossy: table ${t} has a row that does not survive a JSON round trip (NaN, Infinity, bigint, Date, undefined, Buffer...); nothing was written. Please fix that row first`) }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
res.stage = 'backupDb'
|
|
226
|
+
try {
|
|
227
|
+
await backupDb()
|
|
228
|
+
res.dbBackup = true
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return { stage: 'backupDb', err }
|
|
232
|
+
}
|
|
233
|
+
res.stage = 'backupRows'
|
|
234
|
+
try {
|
|
235
|
+
let fp = path.resolve(backupPath || `./${tool}-backup-${now2strp()}.json`)
|
|
236
|
+
res.backupPath = fp
|
|
237
|
+
let body = { meta: { tool, time: new Date().toISOString(), ...meta, note: '四表全部列(寫入前)之備份; 含個資, 請妥善保管.' } }
|
|
238
|
+
for (let t of TABLES) {
|
|
239
|
+
body[t] = rows0[t]
|
|
240
|
+
}
|
|
241
|
+
fs.writeFileSync(fp, JSON.stringify(body), { encoding: 'utf8', flag: 'wx' }) //wx: 既有檔不覆寫
|
|
242
|
+
let back = JSON.parse(fs.readFileSync(fp, 'utf8'))
|
|
243
|
+
for (let t of TABLES) {
|
|
244
|
+
if (!Array.isArray(back[t]) || back[t].length !== rows0[t].length) {
|
|
245
|
+
throw new Error(`backup file verification failed: ${t}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
log(`已備份: 資料庫(backup-時間.json, 於工作目錄)、四表全部列 ${fp}`)
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
return { stage: 'backupRows', err }
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
//mkFail: 失敗收尾(印原因、已寫入進度與還原提示)
|
|
258
|
+
let mkFail = (res, log) => (stage, err) => {
|
|
259
|
+
res.ok = false
|
|
260
|
+
res.stage = stage
|
|
261
|
+
res.err = msgOf(err)
|
|
262
|
+
log(`失敗(階段 ${stage}): ${res.err}`)
|
|
263
|
+
if (res.written.length > 0 || res.failed.length > 0) {
|
|
264
|
+
log(`已寫入 ${res.written.length} 筆, 失敗 ${res.failed.length} 筆${res.failed.length > 0 ? ': ' + res.failed.slice(0, 10).map((f) => `${f.table} id=${f.id}(${f.err})`).join('; ') : ''}`)
|
|
265
|
+
}
|
|
266
|
+
if (res.dbBackup || res.backupPath !== '') {
|
|
267
|
+
log(`請依備份檔還原(資料庫備份 backup-時間.json 於工作目錄${res.backupPath !== '' ? `; 四表全部列 ${res.backupPath}` : ''}); 本工具冪等, 修正原因後可原樣重跑同一指令`)
|
|
268
|
+
}
|
|
269
|
+
return res
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------
|
|
274
|
+
// 文字報告
|
|
275
|
+
// ---------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 各表 from 值分佈之文字報告(g_purgeFrom 預設輸出; 亦為 g_fixFromType 之前置說明)
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} audit 輸入 auditFrom 之結果
|
|
281
|
+
* @returns {String} 回傳多行文字
|
|
282
|
+
*/
|
|
283
|
+
function formatFromAudit(audit) {
|
|
284
|
+
let c = audit.counts
|
|
285
|
+
let L = []
|
|
286
|
+
L.push(`資料筆數: ${TABLE_ORDER.map((t) => `${t} ${c.byTable[t].nRows}`).join('、')}`)
|
|
287
|
+
L.push('')
|
|
288
|
+
L.push('== 各表 from 值分佈(表, from 值型別/值: 筆數) ==')
|
|
289
|
+
for (let t of TABLE_ORDER) {
|
|
290
|
+
L.push(`-- ${t}(共 ${c.byTable[t].nRows} 列)`)
|
|
291
|
+
let es = audit.entries.filter((x) => x.table === t)
|
|
292
|
+
if (es.length === 0) {
|
|
293
|
+
L.push(' (無列)')
|
|
294
|
+
}
|
|
295
|
+
for (let x of es) {
|
|
296
|
+
if (x.kind === 'string') {
|
|
297
|
+
L.push(` ${j(x.from)}(外部列, 升版後唯讀): ${x.n}`)
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
L.push(` ${KIND_TXT[x.kind]}: ${x.n}`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
L.push('')
|
|
305
|
+
L.push(`== 升版後將成為外部列(唯讀)之(表, from)與筆數: 共 ${c.nExternal} 列、${audit.external.length} 個(表, from) ==`)
|
|
306
|
+
if (audit.external.length === 0) {
|
|
307
|
+
L.push(' (無: 所有列皆為後台列)')
|
|
308
|
+
}
|
|
309
|
+
for (let x of audit.external) {
|
|
310
|
+
L.push(` ${x.table} from=${j(x.from)}: ${x.n} 列`)
|
|
311
|
+
}
|
|
312
|
+
L.push('')
|
|
313
|
+
L.push('== 提示 ==')
|
|
314
|
+
L.push(`- 非字串 from(null/數字/布林/物件/陣列)共 ${c.nNonString} 列: 為後台列, 升版後後台寫入起拒收非字串 from; 以 node g_fixFromType.mjs 列出、--apply 正規化為 ''(缺欄視同 '', 不須處理).`)
|
|
315
|
+
L.push(`- 非空 from 共 ${c.nExternal} 列: 升版後為外部列, 後台不得改其身分/結構欄、不得刪除、不得改 from(指派欄 users.cgrups/grups.cpemis/pemis.crules 仍可寫); 若該來源已不再同步而須整批移除, 以 node g_purgeFrom.mjs --table <表|all> --from <來源> 列出將被清除之列與連帶清理之引用(預設只列不改), 確認後才加 --apply.`)
|
|
316
|
+
L.push('- 本工具不做「認領」(把外部列改成後台列); 要保留為後台資料者請於升版前於後台處理(或另行評估), 不在本工具範圍.')
|
|
317
|
+
return L.join('\n')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* g_fixFromType 之文字報告: 各表非字串 from 之列數、型別分佈、前 N 個 id
|
|
323
|
+
*
|
|
324
|
+
* @param {Object} plans 輸入 { 表: planFixFromType 之結果 }
|
|
325
|
+
* @returns {String} 回傳多行文字
|
|
326
|
+
*/
|
|
327
|
+
function formatFixReport(plans) {
|
|
328
|
+
let L = []
|
|
329
|
+
let nAll = 0
|
|
330
|
+
L.push('== 非字串 from(null/數字/布林/物件/陣列)之列(將改為 \'\'; 缺欄視同 \'\', 不改) ==')
|
|
331
|
+
for (let t of TABLE_ORDER) {
|
|
332
|
+
let pl = plans[t]
|
|
333
|
+
let c = pl.counts
|
|
334
|
+
nAll += pl.changes.length
|
|
335
|
+
let kinds = Object.keys(c.kinds).filter((k) => c.kinds[k] > 0).map((k) => `${k} ${c.kinds[k]}`).join('、')
|
|
336
|
+
L.push(`-- ${t}: 共 ${c.nRows} 列; 非字串 from ${c.nNonString} 列${kinds !== '' ? `(${kinds})` : ''}; 缺欄 ${c.nMissing} 列(視同 '', 不改); 非空 from(外部列, 不動) ${c.nExternal} 列`)
|
|
337
|
+
if (pl.changes.length > 0) {
|
|
338
|
+
L.push(` 待改前 ${Math.min(MAX_IDS, pl.changes.length)} 個 id: ${pl.changes.slice(0, MAX_IDS).map((x) => j(x.id)).join('、')}${pl.changes.length > MAX_IDS ? `…(其餘 ${pl.changes.length - MAX_IDS} 列從略)` : ''}`)
|
|
339
|
+
}
|
|
340
|
+
if (c.nNoId > 0) {
|
|
341
|
+
L.push(` 另有 ${c.nNoId} 列非字串 from 但缺非空字串主鍵而無從寫回(不改, 須人工處理)`)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
L.push('')
|
|
345
|
+
L.push(`== 將改為 '' 之列共 ${nAll} 列(只改 from 欄, 其餘欄位與更新者/更新時間不動) ==`)
|
|
346
|
+
return L.join('\n')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* g_purgeFrom 指定來源之清除計畫文字報告: 將被清除之列與將被連帶清理之引用端筆數
|
|
352
|
+
*
|
|
353
|
+
* @param {Object} chain 輸入 planPurgeChain 之結果
|
|
354
|
+
* @param {Object} [opt={}] 輸入 { maxList }
|
|
355
|
+
* @returns {String} 回傳多行文字
|
|
356
|
+
*/
|
|
357
|
+
function formatPurgeReport(chain, opt = {}) {
|
|
358
|
+
let maxList = Number.isInteger(opt.maxList) ? opt.maxList : MAX_LIST
|
|
359
|
+
let L = []
|
|
360
|
+
L.push(`來源 from=${j(chain.from)}(精確比對, 不 trim、不分大小寫); 範圍: ${chain.plans.map((p) => p.keyTable).join(' → ')}`)
|
|
361
|
+
for (let pl of chain.plans) {
|
|
362
|
+
L.push('')
|
|
363
|
+
L.push(`== ${pl.keyTable}: 將清除 ${pl.nRows} 列 ==`)
|
|
364
|
+
pl.rows.slice(0, maxList).forEach((r) => L.push(` id=${j(r.id)}${r.name !== undefined && r.name !== r.id ? ` name=${j(r.name)}` : ''}`))
|
|
365
|
+
if (pl.rows.length > maxList) {
|
|
366
|
+
L.push(` …(其餘 ${pl.rows.length - maxList} 列從略)`)
|
|
367
|
+
}
|
|
368
|
+
if (pl.err !== null) {
|
|
369
|
+
L.push(` [拒絕] ${pl.err}: ${ERR_TXT[pl.err] || pl.err}(--apply 亦將拒絕, 零寫入)`)
|
|
370
|
+
}
|
|
371
|
+
if (pl.holder !== '' && pl.nRows > 0 && pl.err === null) {
|
|
372
|
+
L.push(` 連帶清理引用 ${pl.holder}.${pl.field}: 須改寫 ${pl.nHolders} 列(受刪除影響 ${pl.nDelHolders} 列; 其中來源非空(外部應用)者 ${pl.nExternal} 列: 下次外部同步時可能被外部資料覆寫)`)
|
|
373
|
+
if (pl.impact.nAnd > 0) {
|
|
374
|
+
L.push(` 其中以 AND 引用者 ${pl.impact.nAnd} 列: 移除引用後其 AND 限制解除, 授權可能變多`)
|
|
375
|
+
}
|
|
376
|
+
if (pl.impact.nWiden > 0) {
|
|
377
|
+
L.push(` 歷史同名只刪其一(名稱仍有存續列, 引用不清理)致授權可能變多者 ${pl.impact.nWiden} 列`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (pl.holder === '' && pl.nRows > 0) {
|
|
381
|
+
L.push(' (users 無引用端, 不連帶清理)')
|
|
382
|
+
}
|
|
383
|
+
if (pl.keyTable === 'users' && pl.nAdmin > 0) {
|
|
384
|
+
L.push(` 注意: 被清除列中有 ${pl.nAdmin} 列為系統管理員(isAdmin='y')`)
|
|
385
|
+
}
|
|
386
|
+
if (pl.similar.length > 0) {
|
|
387
|
+
L.push(` 提示: 另有相近但不相同之來源(不會被清除): ${pl.similar.map((s) => `${j(s.from)} ${s.n} 列`).join('、')}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (chain.plans.length > 1) {
|
|
391
|
+
L.push('')
|
|
392
|
+
L.push('(逐表依序套用: 較早之表所改寫之引用端列, 可能為稍後同次被清除之列, 仍計入該表之改寫列數)')
|
|
393
|
+
}
|
|
394
|
+
L.push('')
|
|
395
|
+
L.push(`== 合計: 清除 ${chain.totals.nRows} 列, 改寫引用端 ${chain.totals.nHolders} 列(其中 AND 引用 ${chain.totals.nAnd} 列、外部來源 ${chain.totals.nExternal} 列) ==`)
|
|
396
|
+
return L.join('\n')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------
|
|
401
|
+
// 後端占用偵測(apply 前; 須於本行程開啟 lmdb 之前)
|
|
402
|
+
// ---------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
//IN_USE_CODES: Windows 對含「已被他行程開啟之檔案(lmdb 之 data.mdb/lock.mdb)」之資料夾改名時之錯誤碼(2026-10-22 實測: 持有時 EPERM, 閒置時成功)
|
|
405
|
+
const IN_USE_CODES = ['EPERM', 'EBUSY', 'EACCES']
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 偵測資料庫資料夾是否被其他行程占用(如後端 srv.mjs 仍在執行)
|
|
409
|
+
*
|
|
410
|
+
* 原理(僅 Windows): 把 <url>/<dbName> 資料夾改名為暫名再改回; Windows 對含「已被其他行程開啟之檔案」之資料夾改名會失敗(EPERM), 閒置則成功.
|
|
411
|
+
* 非 Windows 平台(改名不受開啟檔影響)無法偵測, 回傳 checked: false, 由呼叫端提示人工確認後端已停止.
|
|
412
|
+
* 判定不明(改名失敗而非上述錯誤碼, 或暫名已存在)一律視為不可寫(fail-closed). 改回失敗時拋錯並指明資料夾暫置之路徑.
|
|
413
|
+
* lmdb 本身允許多行程同時開啟(後端運作中仍可開啟與寫入), 故不可靠「開得起來」判定未被占用.
|
|
414
|
+
*
|
|
415
|
+
* @param {Object} p 輸入參數物件
|
|
416
|
+
* @param {String} p.url 輸入資料庫連線資料夾(如 ./db)
|
|
417
|
+
* @param {String} p.dbName 輸入資料庫名稱(如 wperm)
|
|
418
|
+
* @returns {Object} 回傳 { ok, checked, err }: ok 為 true 表可進行寫入(未偵測到占用或無法偵測); checked 表是否真的偵測過; err 為拒絕原因(ok 為 false 時)
|
|
419
|
+
*/
|
|
420
|
+
function checkDbNotInUse(p) {
|
|
421
|
+
let dir = path.resolve(p.url, p.dbName)
|
|
422
|
+
if (!fs.existsSync(dir)) {
|
|
423
|
+
return { ok: false, checked: false, err: `資料庫不存在: ${dir}` }
|
|
424
|
+
}
|
|
425
|
+
if (process.platform !== 'win32') {
|
|
426
|
+
return { ok: true, checked: false, err: '' }
|
|
427
|
+
}
|
|
428
|
+
let tmp = `${dir}.__inuse-probe-${process.pid}__`
|
|
429
|
+
if (fs.existsSync(tmp)) {
|
|
430
|
+
return { ok: false, checked: true, err: `占用偵測之暫名已存在: ${tmp}(請先確認並移除後重試)` }
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
fs.renameSync(dir, tmp)
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
if (IN_USE_CODES.includes(err && err.code)) {
|
|
437
|
+
return { ok: false, checked: true, err: `資料庫仍被其他行程占用(${err.code}): ${dir}; 請先停止後端(srv.mjs)等占用者後重試` }
|
|
438
|
+
}
|
|
439
|
+
return { ok: false, checked: true, err: `無法判定資料庫是否被占用(${msgOf(err)}): ${dir}; 為安全起見拒絕寫入` }
|
|
440
|
+
}
|
|
441
|
+
let lastErr = null
|
|
442
|
+
for (let i = 0; i < 20; i++) {
|
|
443
|
+
try {
|
|
444
|
+
fs.renameSync(tmp, dir)
|
|
445
|
+
return { ok: true, checked: true, err: '' }
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
lastErr = err
|
|
449
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
throw new Error(`占用偵測改回資料夾名稱失敗(${msgOf(lastErr)}): 資料庫資料夾暫置於 ${tmp}, 請手動改回 ${dir}`)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------
|
|
457
|
+
// 唯讀稽核(g_purgeFrom 預設)
|
|
458
|
+
// ---------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 唯讀: 讀四表並輸出各表 from 值分佈(不寫、不備份)
|
|
462
|
+
*
|
|
463
|
+
* @param {Object} p 輸入參數物件
|
|
464
|
+
* @param {Object} p.woItems 輸入 ORM 資料表物件(四表各有 select)
|
|
465
|
+
* @param {Function} [p.log] 輸入輸出文字之函數, 預設 console.log
|
|
466
|
+
* @returns {Promise<Object>} 回傳 { ok, stage, err, audit }; 不 reject
|
|
467
|
+
*/
|
|
468
|
+
async function runAuditFrom(p = {}) {
|
|
469
|
+
let log = typeof p.log === 'function' ? p.log : console.log
|
|
470
|
+
let res = { ok: false, apply: false, stage: 'check', err: '', audit: null, written: [], failed: [], dbBackup: false, backupPath: '' }
|
|
471
|
+
let fail = mkFail(res, log)
|
|
472
|
+
let bad = checkWo(p.woItems, ['select'])
|
|
473
|
+
if (bad !== '') {
|
|
474
|
+
return fail('check', bad)
|
|
475
|
+
}
|
|
476
|
+
let rows0 = null
|
|
477
|
+
try {
|
|
478
|
+
rows0 = await readTables(p.woItems)
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
return fail('select', err)
|
|
482
|
+
}
|
|
483
|
+
res.audit = auditFrom(rows0)
|
|
484
|
+
log(formatFromAudit(res.audit))
|
|
485
|
+
res.ok = true
|
|
486
|
+
res.stage = 'audit'
|
|
487
|
+
return res
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------
|
|
492
|
+
// g_fixFromType: 非字串 from 正規化為 ''
|
|
493
|
+
// ---------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 稽核並(apply 時)把非字串 from 正規化為 ''
|
|
497
|
+
*
|
|
498
|
+
* @param {Object} p 輸入參數物件
|
|
499
|
+
* @param {Object} p.woItems 輸入 ORM 資料表物件(四表各有 select/save)
|
|
500
|
+
* @param {Boolean} [p.apply=false] 輸入是否寫入, 預設 false(只列不改)
|
|
501
|
+
* @param {String} [p.backupPath] 輸入四表備份檔路徑, 預設 ./fixFromType-backup-<時間戳>.json(相對工作目錄)
|
|
502
|
+
* @param {Function} [p.backupDb] 輸入資料庫備份函數(回傳 Promise, reject 表失敗), 預設 getDb.backup(woItems)
|
|
503
|
+
* @param {Function} [p.log] 輸入輸出文字之函數, 預設 console.log
|
|
504
|
+
* @returns {Promise<Object>} 回傳 { ok, apply, stage, err, plans, nChanges, dbBackup, backupPath, written, failed, stale, nNoId }; ok 為 false 時 stage 為失敗之階段(check/select/backupDb/backupRows/stale/write/verify); 不 reject
|
|
505
|
+
*/
|
|
506
|
+
async function runFixFromType(p = {}) {
|
|
507
|
+
let woItems = p.woItems
|
|
508
|
+
let apply = p.apply === true
|
|
509
|
+
let log = typeof p.log === 'function' ? p.log : console.log
|
|
510
|
+
let backupDb = typeof p.backupDb === 'function' ? p.backupDb : () => getDb.backup(woItems)
|
|
511
|
+
let res = { ok: false, apply, stage: 'check', err: '', plans: null, nChanges: 0, nNoId: 0, dbBackup: false, backupPath: '', written: [], failed: [], stale: [] }
|
|
512
|
+
let fail = mkFail(res, log)
|
|
513
|
+
|
|
514
|
+
//check
|
|
515
|
+
let bad = checkWo(woItems, apply ? ['select', 'save'] : ['select'])
|
|
516
|
+
if (bad !== '') {
|
|
517
|
+
return fail('check', bad)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
//select, plan
|
|
521
|
+
let rows0 = null
|
|
522
|
+
try {
|
|
523
|
+
rows0 = await readTables(woItems)
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
return fail('select', err)
|
|
527
|
+
}
|
|
528
|
+
let plans = {}
|
|
529
|
+
for (let t of TABLE_ORDER) {
|
|
530
|
+
plans[t] = planFixFromType(rows0[t])
|
|
531
|
+
}
|
|
532
|
+
res.plans = plans
|
|
533
|
+
res.nChanges = TABLE_ORDER.reduce((a, t) => a + plans[t].changes.length, 0)
|
|
534
|
+
res.nNoId = TABLE_ORDER.reduce((a, t) => a + plans[t].counts.nNoId, 0)
|
|
535
|
+
log(formatFixReport(plans))
|
|
536
|
+
let noIdNote = res.nNoId > 0 ? `另有 ${res.nNoId} 列非字串 from 缺非空字串主鍵而無從寫回, 本工具不改, 須人工處理` : ''
|
|
537
|
+
if (!apply) {
|
|
538
|
+
log('')
|
|
539
|
+
log('(預設只列不改; 確認清單後加 --apply 才寫入, 寫入前會自動備份; 後端須先停止)')
|
|
540
|
+
res.ok = true
|
|
541
|
+
res.stage = 'audit'
|
|
542
|
+
return res
|
|
543
|
+
}
|
|
544
|
+
if (res.nChanges === 0) {
|
|
545
|
+
log('')
|
|
546
|
+
log('無須修正(沒有可改之非字串 from), 不備份、不寫入.')
|
|
547
|
+
if (noIdNote !== '') {
|
|
548
|
+
log(noIdNote)
|
|
549
|
+
}
|
|
550
|
+
res.ok = true
|
|
551
|
+
res.stage = 'audit'
|
|
552
|
+
return res
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
//backup
|
|
556
|
+
let bk = await backupBefore({ res, rows0, tool: 'fixFromType', meta: {}, backupPath: p.backupPath, backupDb, log })
|
|
557
|
+
if (bk !== null) {
|
|
558
|
+
return fail(bk.stage, bk.err)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
//stale
|
|
562
|
+
res.stage = 'stale'
|
|
563
|
+
let rows1 = null
|
|
564
|
+
try {
|
|
565
|
+
rows1 = await readTables(woItems)
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
return fail('stale', err)
|
|
569
|
+
}
|
|
570
|
+
res.stale = diffTables(rows0, rows1)
|
|
571
|
+
if (res.stale.length > 0) {
|
|
572
|
+
return fail('stale', `tables changed since the audit: ${res.stale.join(', ')}`)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
//write
|
|
576
|
+
res.stage = 'write'
|
|
577
|
+
for (let t of TABLE_ORDER) {
|
|
578
|
+
if (plans[t].changes.length === 0) {
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
let r = await saveRows(woItems[t], t, plans[t].changes.map((c) => ({ id: c.id, from: c.after })))
|
|
582
|
+
res.written.push(...r.written)
|
|
583
|
+
res.failed.push(...r.failed)
|
|
584
|
+
if (r.failed.length > 0) {
|
|
585
|
+
return fail('write', `${r.failed.length} rows failed to save in ${t}`)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
//verify: 預期 = 該些列 from 改為 ''、其餘逐位元不變; 且再規劃無待改列
|
|
590
|
+
res.stage = 'verify'
|
|
591
|
+
try {
|
|
592
|
+
let rows2 = await readTables(woItems)
|
|
593
|
+
let exp = {}
|
|
594
|
+
for (let t of TABLES) {
|
|
595
|
+
let m = new Map(plans[t].changes.map((c) => [c.id, c]))
|
|
596
|
+
exp[t] = rows0[t].map((r) => (m.has(rowId(r)) ? { ...r, from: '' } : r))
|
|
597
|
+
}
|
|
598
|
+
let diff = diffTables(rows2, exp)
|
|
599
|
+
if (diff.length > 0) {
|
|
600
|
+
return fail('verify', `rows differ from expected after write: ${diff.join(', ')}`)
|
|
601
|
+
}
|
|
602
|
+
let rest = TABLE_ORDER.reduce((a, t) => a + planFixFromType(rows2[t]).changes.length, 0)
|
|
603
|
+
if (rest !== 0) {
|
|
604
|
+
return fail('verify', `${rest} non-string from remain after fix`)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
return fail('verify', err)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
res.ok = true
|
|
612
|
+
res.stage = 'done'
|
|
613
|
+
let n = (t) => res.written.filter((w) => w.table === t).length
|
|
614
|
+
log('')
|
|
615
|
+
log(`已修正: 寫入 ${res.written.length} 列(${TABLE_ORDER.map((t) => `${t} ${n(t)}`).join('、')}); 重新稽核已無非字串 from(缺主鍵者除外).`)
|
|
616
|
+
if (noIdNote !== '') {
|
|
617
|
+
log(noIdNote)
|
|
618
|
+
}
|
|
619
|
+
return res
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------
|
|
624
|
+
// g_purgeFrom: 整來源清除
|
|
625
|
+
// ---------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* 規劃並(apply 時)整來源清除: 刪除指定表中 from 精確等於指定值之全部列, 並於同一次清理引用
|
|
629
|
+
*
|
|
630
|
+
* 順序(逐表依 targets → pemis → grups → users): 先寫引用端改寫(只改引用欄位)、後刪被清除列; 任一步失敗即中止(不自動還原, 依備份提示手動回復; 本工具冪等, 可重跑).
|
|
631
|
+
* 寫後比對「規劃套用後之預測狀態」並檢查不變式: 指定表內已無 from 為該值之列; 清除未製造新的懸空引用(寫後之懸空項皆已存在於寫前).
|
|
632
|
+
*
|
|
633
|
+
* @param {Object} p 輸入參數物件
|
|
634
|
+
* @param {Object} p.woItems 輸入 ORM 資料表物件(四表各有 select/save/del)
|
|
635
|
+
* @param {String} p.table 輸入表名('targets'、'pemis'、'grups'、'users' 或 'all')
|
|
636
|
+
* @param {String} p.from 輸入來源名稱(非空字串, 精確比對; 空字串一律拒絕)
|
|
637
|
+
* @param {Boolean} [p.apply=false] 輸入是否寫入, 預設 false(只列不改)
|
|
638
|
+
* @param {String} [p.backupPath] 輸入四表備份檔路徑, 預設 ./purgeFrom-backup-<時間戳>.json(相對工作目錄)
|
|
639
|
+
* @param {Function} [p.backupDb] 輸入資料庫備份函數, 預設 getDb.backup(woItems)
|
|
640
|
+
* @param {Function} [p.planFn] 輸入規劃函數(僅供測試注入), 預設 planPurgeChain
|
|
641
|
+
* @param {Function} [p.log] 輸入輸出文字之函數, 預設 console.log
|
|
642
|
+
* @returns {Promise<Object>} 回傳 { ok, apply, stage, err, chain, nRows, dbBackup, backupPath, written, failed, stale }; 規劃被拒時 ok 為 false、stage 為 plan(dry-run 亦然, 表示 --apply 亦將拒絕); 不 reject
|
|
643
|
+
*/
|
|
644
|
+
async function runPurgeFrom(p = {}) {
|
|
645
|
+
let woItems = p.woItems
|
|
646
|
+
let apply = p.apply === true
|
|
647
|
+
let log = typeof p.log === 'function' ? p.log : console.log
|
|
648
|
+
let backupDb = typeof p.backupDb === 'function' ? p.backupDb : () => getDb.backup(woItems)
|
|
649
|
+
let planFn = typeof p.planFn === 'function' ? p.planFn : planPurgeChain //planFn 僅供測試注入有缺陷之規劃器, 以驗證寫後不變式檢查(正式使用不給)
|
|
650
|
+
let res = { ok: false, apply, stage: 'check', err: '', chain: null, nRows: 0, dbBackup: false, backupPath: '', written: [], failed: [], stale: [] }
|
|
651
|
+
let fail = mkFail(res, log)
|
|
652
|
+
|
|
653
|
+
//check
|
|
654
|
+
let bad = checkWo(woItems, apply ? ['select', 'save', 'del'] : ['select'])
|
|
655
|
+
if (bad !== '') {
|
|
656
|
+
return fail('check', bad)
|
|
657
|
+
}
|
|
658
|
+
if (typeof p.from !== 'string' || p.from === '') {
|
|
659
|
+
return fail('check', 'from must be a non-empty string')
|
|
660
|
+
}
|
|
661
|
+
let keyTables = p.table === 'all' ? TABLE_ORDER : [p.table]
|
|
662
|
+
if (!keyTables.every((t) => TABLE_ORDER.includes(t))) {
|
|
663
|
+
return fail('check', `invalid table: ${String(p.table)}`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
//select, plan
|
|
667
|
+
let rows0 = null
|
|
668
|
+
try {
|
|
669
|
+
rows0 = await readTables(woItems)
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
return fail('select', err)
|
|
673
|
+
}
|
|
674
|
+
let chain = planFn({ from: p.from, tables: rows0, keyTables })
|
|
675
|
+
res.chain = chain
|
|
676
|
+
res.nRows = chain.totals.nRows
|
|
677
|
+
log(formatPurgeReport(chain))
|
|
678
|
+
if (chain.err !== null) {
|
|
679
|
+
return fail('plan', `purge refused at ${chain.errTable}: ${chain.err}${ERR_TXT[chain.err] ? `(${ERR_TXT[chain.err]})` : ''}; nothing written`)
|
|
680
|
+
}
|
|
681
|
+
if (!apply) {
|
|
682
|
+
log('')
|
|
683
|
+
log('(預設只列不改; 確認清單後加 --apply 才寫入——不可逆, 寫入前會自動備份; 後端須先停止)')
|
|
684
|
+
res.ok = true
|
|
685
|
+
res.stage = 'audit'
|
|
686
|
+
return res
|
|
687
|
+
}
|
|
688
|
+
if (res.nRows === 0) {
|
|
689
|
+
log('')
|
|
690
|
+
log('無須清除(沒有 from 精確等於指定值之列), 不備份、不寫入.')
|
|
691
|
+
res.ok = true
|
|
692
|
+
res.stage = 'audit'
|
|
693
|
+
return res
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
//backup
|
|
697
|
+
let bk = await backupBefore({ res, rows0, tool: 'purgeFrom', meta: { table: p.table, from: p.from }, backupPath: p.backupPath, backupDb, log })
|
|
698
|
+
if (bk !== null) {
|
|
699
|
+
return fail(bk.stage, bk.err)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
//stale
|
|
703
|
+
res.stage = 'stale'
|
|
704
|
+
let rows1 = null
|
|
705
|
+
try {
|
|
706
|
+
rows1 = await readTables(woItems)
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
return fail('stale', err)
|
|
710
|
+
}
|
|
711
|
+
res.stale = diffTables(rows0, rows1)
|
|
712
|
+
if (res.stale.length > 0) {
|
|
713
|
+
return fail('stale', `tables changed since the audit: ${res.stale.join(', ')}`)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
//write: 逐表(引用鏈序)先寫引用端改寫、後刪被清除列
|
|
717
|
+
res.stage = 'write'
|
|
718
|
+
for (let pl of chain.plans) {
|
|
719
|
+
if (pl.changes.length > 0) {
|
|
720
|
+
let r = await saveRows(woItems[pl.holder], pl.holder, pl.changes.map((c) => ({ id: c.id, [c.field]: c.after })))
|
|
721
|
+
res.written.push(...r.written.map((w) => ({ ...w, op: 'save' })))
|
|
722
|
+
res.failed.push(...r.failed)
|
|
723
|
+
if (r.failed.length > 0) {
|
|
724
|
+
return fail('write', `${r.failed.length} holder rows failed to save in ${pl.holder} (purge of ${pl.keyTable} not started)`)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (pl.delIds.length > 0) {
|
|
728
|
+
let r = await delRows(woItems[pl.keyTable], pl.keyTable, pl.delIds)
|
|
729
|
+
res.written.push(...r.written.map((w) => ({ ...w, op: 'del' })))
|
|
730
|
+
res.failed.push(...r.failed)
|
|
731
|
+
if (r.failed.length > 0) {
|
|
732
|
+
return fail('write', `${r.failed.length} rows failed to delete in ${pl.keyTable}`)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
//verify: 與預測狀態逐位元相同; 指定表已無該來源之列; 未製造新的懸空引用
|
|
738
|
+
res.stage = 'verify'
|
|
739
|
+
try {
|
|
740
|
+
let rows2 = await readTables(woItems)
|
|
741
|
+
let diff = diffTables(rows2, chain.tablesAfter)
|
|
742
|
+
if (diff.length > 0) {
|
|
743
|
+
return fail('verify', `tables differ from the predicted state after write: ${diff.join(', ')}`)
|
|
744
|
+
}
|
|
745
|
+
for (let t of keyTables) {
|
|
746
|
+
let left = rows2[t].filter((r) => r !== null && typeof r === 'object' && r.from === p.from).length
|
|
747
|
+
if (left !== 0) {
|
|
748
|
+
return fail('verify', `${left} rows of ${t} still have the purged from`)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
let key = (x) => [x.table, x.holderId, x.field, x.name].map((v) => j(v)).join('\u0000')
|
|
752
|
+
let before = new Set(auditRefs(rows0).items.map(key))
|
|
753
|
+
let fresh = auditRefs(rows2).items.filter((x) => !before.has(key(x)))
|
|
754
|
+
if (fresh.length > 0) {
|
|
755
|
+
return fail('verify', `${fresh.length} new dangling refs after purge`)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
return fail('verify', err)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
res.ok = true
|
|
763
|
+
res.stage = 'done'
|
|
764
|
+
let nDel = res.written.filter((w) => w.op === 'del').length
|
|
765
|
+
let nSave = res.written.filter((w) => w.op === 'save').length
|
|
766
|
+
log('')
|
|
767
|
+
log(`已清除: 刪除 ${nDel} 列、改寫引用端 ${nSave} 列; 寫後狀態與預測逐位元相同, 指定來源已無殘列, 未製造新的懸空引用. 請重啟後端並抽查授權.`)
|
|
768
|
+
return res
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
export { TABLES, isJsonLossy, checkDbNotInUse, formatFromAudit, formatFixReport, formatPurgeReport, runAuditFrom, runFixFromType, runPurgeFrom }
|
|
773
|
+
</code></pre>
|
|
774
|
+
</article>
|
|
775
|
+
</section>
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
</div>
|
|
783
|
+
|
|
784
|
+
<br class="clear">
|
|
785
|
+
|
|
786
|
+
<footer>
|
|
787
|
+
Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Fri Oct 23 2026 10:56:39 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
|
|
788
|
+
</footer>
|
|
789
|
+
|
|
790
|
+
<script>prettyPrint();</script>
|
|
791
|
+
<script src="scripts/polyfill.js"></script>
|
|
792
|
+
<script src="scripts/linenumber.js"></script>
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
</body>
|
|
797
|
+
</html>
|