w-web-perm 1.1.13 → 1.1.15

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.
Files changed (114) hide show
  1. package/README.md +98 -7
  2. package/dist/css/{app.f09c7433.css → app.f24c5e50.css} +1 -1
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.307bef4f.js +1 -0
  5. package/dist/js/app.307bef4f.js +1 -0
  6. package/dist/js/{chunk-vendors.ece5a361.js → chunk-vendors-legacy.6cc4d11e.js} +1 -1
  7. package/dist/js/{chunk-vendors-legacy.ece5a361.js → chunk-vendors.6cc4d11e.js} +1 -1
  8. package/dist/w-web-perm.umd.js +2 -2
  9. package/dist/w-web-perm.umd.js.map +1 -1
  10. package/docs/WWebPerm.html +15 -7
  11. package/docs/global.html +11733 -5078
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +154 -345
  14. package/docs/server_adminCheck.mjs.html +92 -0
  15. package/docs/server_cascadeWrite.mjs.html +5 -26
  16. package/docs/server_dataChannel.mjs.html +4 -4
  17. package/docs/server_keyCheck.mjs.html +150 -23
  18. package/docs/server_lockSave.mjs.html +2 -2
  19. package/docs/server_maskLog.mjs.html +2 -2
  20. package/docs/server_procCore.mjs.html +261 -65
  21. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  22. package/docs/server_syncCore.mjs.html +533 -0
  23. package/docs/server_syncKeep.mjs.html +27 -20
  24. package/docs/server_tools_authRefsAudit.mjs.html +30 -10
  25. package/docs/server_tools_authRefsClean.mjs.html +2 -2
  26. package/docs/server_tools_fromTools.mjs.html +2 -2
  27. package/docs/server_tools_isMain.mjs.html +2 -2
  28. package/docs/server_tools_nameDupReport.mjs.html +40 -7
  29. package/docs/server_validateRows.mjs.html +2 -2
  30. package/docs/server_wrapInjected.mjs.html +2 -2
  31. package/docs/src_fetchJson.mjs.html +2 -2
  32. package/docs/src_getPermUserInfor.mjs.html +175 -0
  33. package/docs/src_js_applySaved.mjs.html +128 -0
  34. package/docs/src_js_cellEditing.mjs.html +163 -0
  35. package/docs/src_js_cvKpToArr.mjs.html +2 -2
  36. package/docs/src_js_cvKpToObj.mjs.html +2 -2
  37. package/docs/src_js_draftSync.mjs.html +83 -7
  38. package/docs/src_js_eachOwn.mjs.html +2 -2
  39. package/docs/src_js_editorRefRows.mjs.html +2 -2
  40. package/docs/src_js_externalRows.mjs.html +34 -10
  41. package/docs/src_js_externalUi.mjs.html +4 -8
  42. package/docs/src_js_findRefRows.mjs.html +2 -2
  43. package/docs/src_js_findTouchedDup.mjs.html +2 -2
  44. package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
  45. package/docs/src_js_fromAudit.mjs.html +2 -2
  46. package/docs/src_js_getAddImpact.mjs.html +2 -2
  47. package/docs/src_js_getAuthWarn.mjs.html +2 -2
  48. package/docs/src_js_getCascadePreview.mjs.html +2 -2
  49. package/docs/src_js_getDelRefCount.mjs.html +2 -2
  50. package/docs/src_js_getEffMode.mjs.html +2 -2
  51. package/docs/src_js_getKpLossy.mjs.html +2 -2
  52. package/docs/src_js_getNameErrs.mjs.html +2 -2
  53. package/docs/src_js_getOwn.mjs.html +2 -2
  54. package/docs/src_js_getPropKey.mjs.html +2 -2
  55. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  56. package/docs/src_js_isAuthMode.mjs.html +2 -2
  57. package/docs/src_js_joinNames.mjs.html +2 -2
  58. package/docs/src_js_legendFit.mjs.html +2 -2
  59. package/docs/src_js_pairRowsToSnapshot.mjs.html +2 -2
  60. package/docs/src_js_refAudit.mjs.html +2 -2
  61. package/docs/src_js_refCascade.mjs.html +2 -2
  62. package/docs/src_js_refIdentity.mjs.html +500 -0
  63. package/docs/src_js_relDialogSend.mjs.html +2 -2
  64. package/docs/src_js_rowDigest.mjs.html +75 -145
  65. package/docs/src_plugins_dataReloadGuard.mjs.html +2 -2
  66. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  67. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  68. package/g_provideGrups.mjs +1 -1
  69. package/g_providePemis.mjs +1 -1
  70. package/g_provideTargets.mjs +1 -1
  71. package/g_provideUsers.mjs +1 -1
  72. package/package.json +2 -2
  73. package/server/WWebPerm.mjs +152 -343
  74. package/server/adminCheck.mjs +20 -0
  75. package/server/cascadeWrite.mjs +3 -24
  76. package/server/dataChannel.mjs +2 -2
  77. package/server/keyCheck.mjs +148 -21
  78. package/server/procCore.mjs +259 -63
  79. package/server/procLang.mjs +78 -4
  80. package/server/provideTabs.mjs +6 -4
  81. package/server/syncCore.mjs +461 -0
  82. package/server/syncKeep.mjs +25 -18
  83. package/server/tools/authRefsAudit.mjs +28 -8
  84. package/server/tools/nameDupReport.mjs +38 -5
  85. package/src/App.vue +6 -1
  86. package/src/components/LayoutContentGrups.vue +57 -8
  87. package/src/components/LayoutContentPemis.vue +57 -8
  88. package/src/components/LayoutContentTargets.vue +38 -7
  89. package/src/components/LayoutContentUsers.vue +57 -8
  90. package/src/components/LayoutState.vue +12 -0
  91. package/src/components/VeGrupBlngUsers.vue +128 -20
  92. package/src/components/VePemiBlngGrups.vue +128 -20
  93. package/src/getPermUserInfor.mjs +10 -0
  94. package/src/js/applySaved.mjs +56 -0
  95. package/src/js/cellEditing.mjs +91 -0
  96. package/src/js/draftSync.mjs +81 -5
  97. package/src/js/externalRows.mjs +32 -8
  98. package/src/js/externalUi.mjs +2 -6
  99. package/src/js/refIdentity.mjs +428 -0
  100. package/src/js/rowDigest.mjs +73 -143
  101. package/src/plugins/mUI.mjs +5 -0
  102. package/src/schema/contracts/grups.mjs +8 -0
  103. package/src/schema/contracts/index.mjs +17 -0
  104. package/src/schema/contracts/pemis.mjs +8 -0
  105. package/src/schema/contracts/targets.mjs +8 -0
  106. package/src/schema/contracts/users.mjs +13 -0
  107. package/src/schema/tables/grups.mjs +4 -0
  108. package/src/schema/tables/pemis.mjs +4 -0
  109. package/src/schema/tables/targets.mjs +4 -0
  110. package/src/schema/tables/users.mjs +4 -0
  111. package/src/store/mutations.mjs +1 -1
  112. package/srv.mjs +43 -0
  113. package/dist/js/app-legacy.8af87052.js +0 -1
  114. package/dist/js/app.8af87052.js +0 -1
@@ -15,8 +15,10 @@ import procLang from './procLang.mjs'
15
15
  import validateRowsBeforeWrite from './validateRows.mjs'
16
16
  import planRefChanges, { REF_OF, diffRefTargets, fillIdlessIds } from '../src/js/refCascade.mjs'
17
17
  import { restoreExternalOrder, dropServerOnlyDiff, restoreServerFields, checkExternalWrite, hasExternalDiff } from '../src/js/externalRows.mjs'
18
- import { isBaselineValid, checkBaseline } from '../src/js/rowDigest.mjs'
19
- import { findTouchedDup, findDupIndex, withTabLocks, checkUsersWrite } from './keyCheck.mjs'
18
+ import { isBaselineValid, getContract } from '../src/js/rowDigest.mjs'
19
+ import { getRefTarget, parseSubject, parseRefDecl, checkSubjectIdentity, checkRefIdentity } from '../src/js/refIdentity.mjs'
20
+ import { getRejectKey } from 'w-serv-orm/src/baseline.mjs'
21
+ import { findTouchedDup, findDupIndex, withTabLocks, checkUsersWrite, mapKeyOf, loginIdOf, findLoginIdClashDetail } from './keyCheck.mjs'
20
22
  import { checkCascadePlan, writeWithCascade } from './cascadeWrite.mjs'
21
23
 
22
24
 
@@ -63,16 +65,71 @@ function mergePickKeysOnly(ltdtDb, rowsIn, keyDetect, pickKeys) {
63
65
  }
64
66
 
65
67
 
68
+ //拒收鍵(w-serv-orm baseline.mjs 之固定英文鍵) → 本套件語系鍵; 一律提示重新載入(規格第 6 節; R-BASELINE-01):
69
+ // baselineMismatch: 持舊畫面存檔(先動先贏); rowNotFound: 修改或刪除時該列已不存在(已被他人刪除, 不復活); baselineFormat: 基準格式或版本不識(含套內舊版指紋; 不得降級為不帶基準)
70
+ let kpRejectKey = {
71
+ baselineMismatch: 'saveStale',
72
+ rowNotFound: 'saveStale',
73
+ baselineFormat: 'saveBaselineFormat',
74
+ }
75
+
76
+
77
+ /**
78
+ * 用戶端(kpFunExt 第 3 參)的 opt 只採納 baseline、returnRows(是否於回傳附寫入後之列, 布林)、subject(反向對話框之主體身分摘要)與 refs(清單頁新增引用之被引用實體身分宣告),
79
+ * 不接受 pickKeysOnly 等伺服端設定, 更不轉給 procOrm 之 opt(規格第 6 節: procOrm 之 opt 只能由伺服端程式自組);
80
+ * 未給(undefined/null)之 baseline 視為舊客戶端/外部應用(不帶, 本 major 允許; 不比原行為多放行任何寫入); 未給之 subject/refs 自 perm-run03 起僅限「本次存檔不改變引用」者,
81
+ * 改變引用(新增或移除啟用之引用項、新增列帶引用)而未附宣告 → 拒收 saveIdentityRequired(R-REF-IDENTITY-01 規則 7); 給了則一律驗證, 格式不合即拒收(baselineFormat/identityFormat; 不降級為不帶, R-SUBJECT-ID-01、R-REF-IDENTITY-01).
82
+ * subject/refs 在此原樣轉出(不信任其內容), 由 updateTabItems 解析驗證; 驗證所需之表與鎖皆由伺服端依 keyTable 自組(getRefTarget), 用戶端不得指定
83
+ *
84
+ * @param {*} opt 輸入用戶端給之 opt(任何值)
85
+ * @returns {Object} 回傳 { baseline?, returnRows?, subject?, refs? }
86
+ */
87
+ function pickClientOpt(opt) {
88
+ let o = (opt !== null && typeof opt === 'object') ? opt : {}
89
+ let out = {}
90
+ if (o.baseline !== undefined && o.baseline !== null) {
91
+ out.baseline = o.baseline
92
+ }
93
+ if (o.returnRows === true) {
94
+ out.returnRows = true
95
+ }
96
+ if (o.subject !== undefined && o.subject !== null) {
97
+ out.subject = o.subject
98
+ }
99
+ if (o.refs !== undefined && o.refs !== null) {
100
+ out.refs = o.refs
101
+ }
102
+ return out
103
+ }
104
+
105
+
66
106
  //mappingBy: 使用者對應鍵('id'|'email'|'name', WWebPerm 已正規化; 未給或其他值視同 'id'), 供 users 整表寫入的對應鍵檢查(R-PRINCIPLE-00 適用表 h, R-MAPKEY-03)
67
107
  //cascadeRename: 群組/權限改名時是否連帶更新引用(預設 true; 只管改名, 刪除連帶清理不受影響, R-REF-CASCADE-01)
68
108
  //lockExternalRows: 後台是否不得改外部列(from 為非空字串者)之身分/結構欄(預設 true, R-EXT-OWN-01; 'n' 時回到舊行為, 僅保留 from 型別檢查)
69
- function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascadeRename = true, lockExternalRows = true }) {
70
-
71
-
72
- //pickClientOpt: 用戶端(kpFunExt 第 3 參)只採納 baseline, 不接受 pickKeysOnly 等伺服端設定; 未給(undefined/null)視為舊客戶端
73
- let pickClientOpt = (opt) => {
74
- let b = (opt !== null && typeof opt === 'object') ? opt.baseline : undefined
75
- return (b === undefined || b === null) ? {} : { baseline: b }
109
+ //withTabLocks(別名 lockTabs), guardWrite: w-serv-orm 實例(WServOrm 回傳物件)之每表一鍵表鎖與原子防護(R-LOCK-CORE-01), 與外部同步(WWebPerm.syncAndReplaceTabs)共用同一個鎖管理器; 未注入即 TypeError(fail-closed, 不寫入)
110
+ function proc(woItems, procOrm, { srLog, withTabLocks: lockTabs, guardWrite, lockSave, mappingBy = 'id', cascadeRename = true, lockExternalRows = true }) {
111
+
112
+
113
+ //mapGuardErr: guardWrite/procOrm(mustExist)之拒收轉本套件語系鍵並記 srLog(只記錯誤鍵、原因與差異筆數, 不記列 id/個資值); rowsInvalid(庫內主鍵缺漏或重複)屬伺服端資料錯誤, 回 saveWriteFail 並記 log;
114
+ //其餘(語系鍵字串、ORM 失敗、非預期例外)原樣轉出(由 errLog/toOutKey 處置)
115
+ let mapGuardErr = (keyTable, userId) => (err) => {
116
+ let k = getRejectKey(err)
117
+ if (typeof k === 'string' && Object.prototype.hasOwnProperty.call(kpRejectKey, k)) {
118
+ let n = 0
119
+ let reason = k
120
+ if (err !== null && typeof err === 'object' && Array.isArray(err.changed)) {
121
+ n = err.changed.length + err.removed.length + err.added.length
122
+ reason = err.changed.length > 0 ? 'rowChanged' : (err.removed.length > 0 ? 'rowRemoved' : (err.added.length > 0 ? 'rowAdded' : (err.reordered === true ? 'rowReordered' : k)))
123
+ n = n > 0 ? n : (err.reordered === true ? 1 : 0)
124
+ }
125
+ srLog.error({ event: 'updateTabItems-stale-error', keyTable, userId, msg: kpRejectKey[k], reason, n })
126
+ return Promise.reject(kpRejectKey[k])
127
+ }
128
+ if (k === 'rowsInvalid') {
129
+ srLog.error({ event: 'updateTabItems-rows-invalid', keyTable, userId, msg: 'saveWriteFail', reason: 'rowsInvalid' })
130
+ return Promise.reject('saveWriteFail')
131
+ }
132
+ return Promise.reject(err)
76
133
  }
77
134
 
78
135
 
@@ -89,13 +146,39 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
89
146
  return Promise.reject('saveRowFieldInvalid')
90
147
  }
91
148
 
92
- //baseline: 草稿所依據之載入基準 [[id, digest], ...](F-26, R-BASELINE-01; 前端於開啟編輯時綁定); 未給(舊客戶端)為 null; 給了但格式不合視為無效請求, 於任何讀寫之前拒絕
149
+ //baseline: 草稿所依據之載入基準 [[id, digest], ...](h1 版, w-serv-orm baseline.mjs; F-26, R-BASELINE-01; 前端於開啟編輯時綁定); 未給(舊客戶端、外部應用)為 null;
150
+ //給了但格式或版本不識(含套內舊版指紋、非陣列)→ 拒收 saveBaselineFormat(提示重新載入, 不降級為不帶基準), 於任何讀寫之前
93
151
  let baseline = get(opt, 'baseline', null)
94
152
  if (baseline !== null && !isBaselineValid(baseline)) {
95
- srLog.error({ event: 'updateTabItems-validate-error', keyTable, msg: 'saveRowFieldInvalid', reason: 'baselineInvalid', row: -1, field: '' })
96
- return Promise.reject('saveRowFieldInvalid')
153
+ srLog.error({ event: 'updateTabItems-stale-error', keyTable, userId, msg: 'saveBaselineFormat', reason: 'baselineFormat', n: 0 })
154
+ return Promise.reject('saveBaselineFormat')
97
155
  }
98
156
 
157
+ //被引用實體身分(perm-run02 範圍一; R-SUBJECT-ID-01、R-REF-IDENTITY-01; 使用者 2026-10-23 裁示「正反關聯與主體身份檢查, 前後端都要做」; 原則 4: 檢查與寫入同一把鎖、原則 6: 引用不得被同名新資料接管):
158
+ // subject: 反向關聯對話框(users 之主體為 grups、grups 之主體為 pemis)開框時之主體摘要 { id, name, saved }; refs: 清單頁新增或移除之啟用引用項其名稱在選取當時對應之實體 id 宣告 [{ name, ids }].
159
+ // 被引用表由伺服端依 keyTable 自組(getRefTarget, 用戶端不得指定), 於本表鎖之外層取其表鎖(階層: 被引用者先、引用端後, R-WRITE-LOCK-02)並新鮮讀取後驗證;
160
+ // perm-run03 範圍一(使用者 2026-10-23 裁示「這本來就該強制必帶吧? 要不然怎麼確認與驗證調用者身份?」): 宣告強制必帶——存檔改變了引用(新增或移除啟用之引用項、新增列帶引用)而未附宣告 → 拒收 saveIdentityRequired
161
+ // (於 write 內本表鎖下判定、零寫入; 無宣告者不取被引用表鎖: 無從驗證即拒收, 不需讀被引用表); 不改變引用之存檔(只改名、只改其他欄)不需宣告, 照常. 給了但格式不合或該表無授權引用欄 → 拒收 saveIdentityFormat(不降級), 於任何讀寫之前
162
+ let subjectRaw = get(opt, 'subject', null)
163
+ let refsRaw = get(opt, 'refs', null)
164
+ let refInfo = getRefTarget(keyTable) //本表有授權引用欄者(users/grups/pemis)為 { table, field, kind }; targets 為 null(無需宣告)
165
+ let refTarget = null
166
+ let subject = null
167
+ let refDecl = null
168
+ if (subjectRaw !== null || refsRaw !== null) {
169
+ refTarget = refInfo
170
+ subject = subjectRaw === null ? null : parseSubject(subjectRaw)
171
+ refDecl = refsRaw === null ? null : parseRefDecl(refsRaw)
172
+ //主體摘要只用於反向對話框(主體表 grups/pemis, 有 name 欄); pemis 之被引用表為 targets(無 name、無反向對話框), 帶 subject 屬格式錯誤
173
+ if (refTarget === null || (subjectRaw !== null && (subject === null || refTarget.table === 'targets')) || (refsRaw !== null && refDecl === null)) {
174
+ srLog.error({ event: 'updateTabItems-identity-error', keyTable, userId, msg: 'saveIdentityFormat', reason: 'format', n: 0 })
175
+ return Promise.reject('saveIdentityFormat')
176
+ }
177
+ }
178
+
179
+ //returnRows: 存檔成功後於同一表鎖內新鮮讀回該表全部列隨回傳附上(規格第 8 節): 前端立即以之更新畫面與基準, 不必等推播/輪詢; 不以前端送出之值當新基準(寫入時 clearXSS 等會改寫字串)
180
+ let returnRows = get(opt, 'returnRows', false) === true
181
+
99
182
  //pickKeysOnly, 僅採納既有列之指定欄位變更(見 mergePickKeysOnly), 未給則整表 diff 寫入
100
183
  let pickKeysOnly = get(opt, 'pickKeysOnly', null)
101
184
 
@@ -110,23 +193,9 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
110
193
  })
111
194
  }
112
195
 
113
- //雙擊防護(後端): 同一操作者之同表寫入處理中再送出即拒絕 'saveInProgress'(不排隊, 見 lockSave.mjs, ADR-025);
114
- //占位包在 kmx 之外層, 不同操作者之並行寫入仍由 kmx 序列化、各自成功(ADR-012)
115
- //序列化同表之並行整表批次寫入: 防 lost update (同 keyTable 序列化, 不同表並行)
116
- //持鎖: 先取本表之鎖; 本表有改名或刪除而須改寫引用端時, 才於內層依階層再取引用端表之鎖(見下方「引用連帶」段). 皆經單一取鎖函數(R-WRITE-LOCK-02); 與外部同步(WWebPerm.syncAndReplaceTabs)共用同表互斥鍵(外審 sol R10)
117
- return await lockSave(`updateTabItems:${keyTable}`, userId, () => withTabLocks(kmx, [keyTable], async () => {
118
-
119
- //pickKeysOnly, 須於 mutex 內讀取資料庫為基底, 避免與並行寫入 race
120
- if (isearr(pickKeysOnly)) {
121
- //有限數字 id 先轉為字串形式再與庫內對應(外審 sol R7): mergePickKeysOnly 只認非空字串 id, 數字 id(如 1)對不上庫內之 '1' 而該列指派被靜默略過卻回成功(R-ORM-04 之同一正規化, 此處原晚於合併)
122
- rows = map(rows, (r) => {
123
- let id = get(r, 'id')
124
- return (typeof id === 'number' && Number.isFinite(id)) ? { ...r, id: String(id) } : r
125
- })
126
- let ltdtDb = await woItems[keyTable].select()
127
- rows = mergePickKeysOnly(ltdtDb, rows, keyDetect, pickKeysOnly)
128
- srLog.info({ event: 'updateTabItems-pickKeysOnly', keyTable, userId, pickKeysOnly })
129
- }
196
+ //prepRows: 寫入前之純函式前處理(不讀庫、不取鎖): ltdtmapping、重給 order、空值/重複檢查、主鍵正規化與重複主鍵檢查; 錯誤以 Promise.reject(語系鍵) 回報(次序與修正前同: 先於基準比對).
197
+ //非 pickKeysOnly 者在取鎖之前執行; pickKeysOnly(users 之 for:grups 模式)須以表鎖內新鮮讀取之庫內列為基底合併後才能前處理(見下方 write)
198
+ let prepRows = async (rows) => {
130
199
 
131
200
  //ltdtmapping
132
201
  rows = ltdtmapping(rows, ds[keyTable].keys)
@@ -197,9 +266,9 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
197
266
  return (typeof id === 'number' && Number.isFinite(id)) ? { ...r, id: String(id) } : r
198
267
  })
199
268
 
200
- //pemis/grups 以 id 對應既有列(見下方 isNameKeyTable 分支), 送入之整表內主鍵 id(字串)不得重複, 否則 Map(id → 列) 靜默併成一列而遺失其一(unit-procCore-writeValidate WV-008);
201
- //缺 id 之列(API 直打, 視為新增)不參與; 錯誤鍵同 validateRowsBeforeWrite 之 idDuplicate: saveRowFieldDuplicate
202
- if (arrHas(keyTable, ['pemis', 'grups'])) {
269
+ //pemis/grups/users 以 id 對應既有列(見下方 isNameKeyTable/users 分支, ltdtDiffByKey 以 Map(id → 列)), 送入之整表內主鍵 id(字串)不得重複, 否則同批兩列被靜默併成一列而遺失其一(unit-procCore-writeValidate WV-008、WV-DUPID);
270
+ //targets 之 id 重複已由上方 ckKey(rows, 'id') 拒收. 四表一併檢查(perm-run01 範圍二, perm-run05 確證 users 漏了此檢查). 缺 id 之列(API 直打, 視為新增)不參與; 錯誤鍵同 validateRowsBeforeWrite 之 idDuplicate: saveRowFieldDuplicate
271
+ if (arrHas(keyTable, ['pemis', 'grups', 'users'])) {
203
272
  let kIdDup = findDupIndex(rows, 'id', (r) => isestr(get(r, 'id'))) //空字串 id 視為未給(待自動產號, validateRows 同), 不參與重複判定(外審 sol R6)
204
273
  if (kIdDup >= 0) {
205
274
  srLog.error({ event: 'updateTabItems-ckKey-error', keyTable, key: 'id', msg: 'saveRowFieldDuplicate', row: kIdDup, value: get(rows, [kIdDup, 'id']) })
@@ -207,19 +276,81 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
207
276
  }
208
277
  }
209
278
 
210
- //ltdtDiffByKey
211
- let ltdtOld = await woItems[keyTable].select()
279
+ return rows
212
280
 
213
- //表級載入基準比對(R-PRINCIPLE-00 原則 7, 規則帳本 R-BASELINE-01, ADR-037 Update(run11); 使用者 2026-10-22 裁示「多人同時編輯以表雜湊比對, 先動先贏」):
214
- //有基準者, 草稿載入時之整表須與目前庫內整表相同(任一列內容指紋不同或列被增刪即過期), 否則整次拒收 saveStale、零寫入, 不部分變更、不上鎖; 指紋排除 order 與伺服器管理欄(見 rowDigest.getDigestFields).
215
- //放在 select 之後、所有檢查與還原之前: 基準過期時下列各檢查之結果(撞名、新增列已存在、外部列守門…)皆以過期之畫面為前提, 應先回「請重新載入」而非誤導之其他訊息.
216
- //無基準者(舊客戶端)不在此檢查: 後台列維持最後寫入者勝(ADR-013), 外部列之指派欄改動另於下方拒收(saveBaselineRequired). srLog 只記錯誤鍵、原因與差異筆數, 不記列 id/個資值
217
- if (baseline !== null) {
218
- let ck = checkBaseline({ keys: ds[keyTable].keys, ltdtOld, baseline })
219
- if (ck !== null) {
220
- srLog.error({ event: 'updateTabItems-stale-error', keyTable, userId, msg: ck.key, reason: ck.reason, n: ck.n })
221
- return Promise.reject(ck.key)
281
+ }
282
+
283
+ //非 pickKeysOnly: 前處理在取鎖之前(純函式, 不讀庫)
284
+ let rowsPre = null
285
+ if (!isearr(pickKeysOnly)) {
286
+ rowsPre = await prepRows(rows)
287
+ }
288
+ else {
289
+ //pickKeysOnly(users 之 for:grups 模式): 送入列於鎖內與庫內列合併後才前處理(prepRows), 合併以 id 為鍵(mergePickKeysOnly 的 kpIn[id] = 列, 同 id 後者覆蓋前者)——
290
+ //送入列內同一 id 出現兩次會被靜默併成一列而遺失其一, 故在合併之前(取鎖之前, 純函式)先以相同口徑拒收(有限數字 id 與同值字串 id 視為同一個, 同合併前之正規化; perm-run01 範圍二)
291
+ let kIdDupIn = findDupIndex(map(rows, (r) => {
292
+ let id = get(r, 'id')
293
+ return (typeof id === 'number' && Number.isFinite(id)) ? { id: String(id) } : r
294
+ }), 'id', (r) => isestr(get(r, 'id')))
295
+ if (kIdDupIn >= 0) {
296
+ srLog.error({ event: 'updateTabItems-ckKey-error', keyTable, key: 'id', msg: 'saveRowFieldDuplicate', row: kIdDupIn, value: get(rows, [kIdDupIn, 'id']) })
297
+ return Promise.reject('saveRowFieldDuplicate')
298
+ }
299
+ }
300
+
301
+ //雙擊防護(後端): 同一操作者之同表寫入處理中再送出即拒絕 'saveInProgress'(不排隊, 見 lockSave.mjs, ADR-025);
302
+ //占位包在表鎖之外層, 不同操作者之並行寫入仍由表鎖序列化、各自成功(ADR-012)
303
+ //表鎖=w-serv-orm 實例之 guardWrite(withTabLocks): 取鎖 → 新鮮讀取 → 載入基準比對 → write 於**同一把表鎖內**(規格第 6、7 節; R-LOCK-CORE-01), 防 lost update 與唯一性檢查被交錯略過;
304
+ //與外部同步(WWebPerm.syncAndReplaceTabs)共用同一個鎖管理器(外審 sol R10). 本表有改名或刪除而須改寫引用端時, 才於 write 內依階層再取引用端表之鎖(見下方「引用連帶」段, R-WRITE-LOCK-02)
305
+ //refRows: 被引用表(grups/pemis/targets)於引用表鎖內之新鮮讀取(僅帶 subject/refs 時; 其餘為 null), 供 subject 事前驗證與 write 內之 refs 驗證共用——兩者與本表寫入在同一段持鎖期間, 其間被引用表不會被任何入口改動(R-LOCK-CORE-01)
306
+ let refRows = null
307
+
308
+ //withRefLock: 未帶 subject/refs 者同修正前(僅本表鎖); 帶了: 先取被引用表鎖(外層, 階層較低)、新鮮讀取、驗主體身分(先於基準比對, 使「主體已被取代」優先於泛稱之 saveStale), 再進本表之 guardWrite
309
+ let withRefLock = (fn) => {
310
+ if (refTarget === null) {
311
+ return fn()
222
312
  }
313
+ return withTabLocks(lockTabs, [refTarget.table], async () => {
314
+ refRows = await woItems[refTarget.table].select()
315
+ if (subject !== null) {
316
+ let si = checkSubjectIdentity({ subject, refRows })
317
+ if (si !== null) {
318
+ srLog.error({ event: 'updateTabItems-identity-error', keyTable, userId, msg: si.key, reason: si.reason, n: 1 })
319
+ return Promise.reject(si.key)
320
+ }
321
+ }
322
+ return await fn()
323
+ })
324
+ }
325
+
326
+ return await lockSave(`updateTabItems:${keyTable}`, userId, () => withRefLock(() => guardWrite({
327
+ tables: [keyTable],
328
+
329
+ //新鮮讀取: 直讀 woItems(WServOrm 未開 useCache), 不經任何快取(規格第 12 節)
330
+ read: () => woItems[keyTable].select(),
331
+
332
+ //表級載入基準比對(R-PRINCIPLE-00 原則 7、使用者 2026-10-22 裁示原則 10「多人同時編輯以表雜湊比對, 先動先贏」; 規則帳本 R-BASELINE-01, ADR-037 Update(perm-run01)):
333
+ //有基準者, 草稿載入時之整表須與目前庫內整表相同(任一列內容摘要不同、列被增刪、名次不同即過期), 否則整次拒收 saveStale、零寫入, 不部分變更、不上鎖; 摘要依寫入契約(排除 order 與伺服器專有欄, 名次入基準).
334
+ //比對在 guardWrite 內、write 之前: 基準過期時下列各檢查之結果(撞名、新增列已存在、外部列守門…)皆以過期之畫面為前提, 應先回「請重新載入」而非誤導之其他訊息.
335
+ //無基準者(舊客戶端、外部應用)不比對(undefined 才跳過; null 不傳到此): 後台列維持最後寫入者勝(ADR-013), 外部列之指派欄改動另於下方拒收(saveBaselineRequired)
336
+ baseline: baseline === null ? undefined : baseline,
337
+ contract: getContract(keyTable),
338
+
339
+ write: async (ltdtOld) => {
340
+
341
+ //rows: 非 pickKeysOnly 為取鎖前已前處理者; pickKeysOnly 於鎖內以新鮮讀取之庫內列為基底合併(避免與並行寫入 race)再前處理
342
+ if (isearr(pickKeysOnly)) {
343
+ //有限數字 id 先轉為字串形式再與庫內對應(外審 sol R7): mergePickKeysOnly 只認非空字串 id, 數字 id(如 1)對不上庫內之 '1' 而該列指派被靜默略過卻回成功(R-ORM-04 之同一正規化, 此處原晚於合併)
344
+ rows = map(rows, (r) => {
345
+ let id = get(r, 'id')
346
+ return (typeof id === 'number' && Number.isFinite(id)) ? { ...r, id: String(id) } : r
347
+ })
348
+ rows = mergePickKeysOnly(ltdtOld, rows, keyDetect, pickKeysOnly)
349
+ srLog.info({ event: 'updateTabItems-pickKeysOnly', keyTable, userId, pickKeysOnly })
350
+ rows = await prepRows(rows)
351
+ }
352
+ else {
353
+ rows = rowsPre
223
354
  }
224
355
 
225
356
  //外部列之 order 由外部擁有(R-EXT-OWN-01): 上方 order=k+1 重編不得改外部列之順序, 還原為庫內值(使其不因後台存檔而成為 diff)
@@ -240,7 +371,7 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
240
371
 
241
372
  //users 之 email/對應鍵(R-PRINCIPLE-00 適用表 h, 規則帳本 R-MAPKEY-03): 遭遇衝突就收嚴, 但只擋「本次造成或觸及」者, 歷史既有之空值/重複原樣送回不拒(否則已有歷史資料之庫無法在後台修復, 連群組指派 pickKeysOnly 也會失敗);
242
373
  //①全表 email 須為非空字串且不重複; ②mappingBy 為 email/name 時另對 isActive==='y' 之列檢查該欄位(含停用改啟用); 判準單一擁有者 keyCheck.checkUsersWrite(與外部同步 F-14 同口徑).
243
- //放在 select 之後、saveNewRowExists 之前(次序同 pemis/grups 之 findTouchedDup), 於任何破壞性寫入之前(本函式在 kmx(tabWriteKey) 內, R-WRITE-LOCK-01); 被拒時資料表零變更.
374
+ //放在 select 之後、saveNewRowExists 之前(次序同 pemis/grups 之 findTouchedDup), 於任何破壞性寫入之前(本函式在 guardWrite 之表鎖內, R-WRITE-LOCK-01、R-LOCK-CORE-01); 被拒時資料表零變更.
244
375
  //srLog 只記錯誤鍵、原因、欄位與列索引, 不記 email/name 之值(個資; 修正前重複時記 value)
245
376
  if (keyTable === 'users') {
246
377
  let ck = checkUsersWrite(rows, ltdtOld, mappingBy)
@@ -286,10 +417,20 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
286
417
  //②from 型別與外部列守門(checkExternalWrite): 新增列 from 須為 ''、外部列不可刪/不可改 from 與身分結構欄, 指派欄可寫(草稿新鮮而仍違規者在此被拒);
287
418
  //③無基準而修改外部列之指派欄: 無從證明快照新舊, Lv1 放行之指派欄不得因舊快照還原外部已撤銷之授權(外審 B.2), 依原則 3 拒收(saveBaselineRequired); 後台列無基準維持舊行為(最後寫入者勝, ADR-013).
288
419
  //srLog 只記錯誤鍵、原因、筆數與來源名稱, 不記列 id/個資值
420
+ //(perm-run01, 寫入契約 serverOnly: 伺服器專有欄位〔建立者/建立時間/更新者/更新時間〕存檔不採用客戶端值〔規格第 4、6 節; R-CONTRACT-01〕:
421
+ //dropServerOnlyDiff/restoreServerFields 改為不分外部列或後台列、亦不論 lockExternalRows 一律套用〔all: true〕——僅伺服器專有欄不同之列視為無變更, 確有改動之列改帶庫內之專有欄位寫入, 客戶端持舊列〔或偽造〕不得回寫.)
289
422
  let keysTab = ds[keyTable].keys
290
- if (lockExternalRows) {
291
- r = dropServerOnlyDiff({ keys: keysTab, r, ltdtOld })
292
- r = restoreServerFields({ r, ltdtOld }) //改動之外部列帶庫內之伺服器管理欄: 持舊草稿(保留未存編輯期間外部同步重寫了該列)不得把較新之建立者/建立時間回退(外審 sol 第 3 點)
423
+ r = dropServerOnlyDiff({ keys: keysTab, r, ltdtOld, all: true })
424
+ r = restoreServerFields({ r, ltdtOld, all: true }) //改動之列帶庫內之伺服器管理欄: 持舊草稿(保留未存編輯期間外部同步重寫了該列)不得把較新之建立者/建立時間回退(外審 sol 第 3 點)
425
+
426
+ //引用身分(perm-run02 範圍一; R-REF-IDENTITY-01; perm-run03 範圍一起宣告強制必帶): 本次寫入之列(新增+改動)相對庫內舊列新增或移除之啟用引用項, 其名稱對應之被引用實體須與選取當時同一身分(同 id), 且宣告須完整;
427
+ //改變了引用卻未附宣告 → saveIdentityRequired; 被刪除、刪除後同名重建(id 不同)、同名新增實體皆整次拒收 saveRefChanged(零寫入): 不得把授權寫到(或移除到)同名新實體(原則 6). refRows 為被引用表鎖內之新鮮讀取(無宣告者未讀取, 為 null)
428
+ if (refInfo !== null) {
429
+ let ri = checkRefIdentity({ keyTable, decl: refDecl, subject, rowsNew: [...r.add, ...r.diff], rowsOld: ltdtOld, refRows })
430
+ if (ri !== null) {
431
+ srLog.error({ event: 'updateTabItems-identity-error', keyTable, userId, msg: ri.key, reason: ri.reason, n: ri.n })
432
+ return Promise.reject(ri.key)
433
+ }
293
434
  }
294
435
  let ex =checkExternalWrite({ keyTable, keys: keysTab, r, ltdtOld, lockExternalRows })
295
436
  if (ex !== null) {
@@ -301,6 +442,23 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
301
442
  return Promise.reject('saveBaselineRequired')
302
443
  }
303
444
 
445
+ //users 登入識別撞值(mappingBy 為 'id' 時; perm-run04, 規則帳本 R-MAPKEY-04, 規格 dspec/外部匯入之定義與機制.md 第 9 節末 O1): 外部列以 srcKey、後台列以 id 對應外部 token 之使用者 id;
446
+ //perm-run05(主控 2026-10-24 收件裁示): 登入識別值須跨全表唯一——不論 isActive、含他來源外部列(登入查找不論有效與否計筆, 放行停用列之撞值, 其後轉有效即造成登入永久拒絕).
447
+ //被本次觸及=新增之列、或登入識別(類別〔後台/外部+來源 from〕+值〔loginIdOf: 後台列為 id、外部列為 srcKey〕)與庫內不同之列(外審 sol#2: lockExternalRows 為假時後台可改 from, 外部列改為後台列即換了登入識別而撞到他列;
448
+ //外部列守門啟用時 from 與 srcKey 不可改, 由上方 checkExternalWrite 先拒——故本檢查置於外部列守門之後, perm-run03 既有之錯誤鍵〔saveExternalRowChanged 等〕不變, 被觸及者實務上只有後台列).
449
+ //停用改啟用只是狀態改變, 登入識別不變, 不算被觸及(歷史撞值不因啟用而新擋; 登入時照拒). 只擋「本次造成或觸及」者(歷史既有撞值原樣送回不擋, 同 R-MAPKEY-03; 後台仍可修復).
450
+ //缺 id 之列無從比對(ORM 補隨機主鍵). 於任何寫入之前(procOrm 在下方). srLog 不記 id/srcKey 之值
451
+ if (keyTable === 'users' && mapKeyOf(mappingBy) === null) {
452
+ //sigOf: 登入識別之「類別+來源+值」(後台列 a/外部列 x:來源; 外審 sol 第二輪#1: 外部列改為後台列而 srcKey 恰等於 id 時值不變、類別已變, 仍須視為被觸及; perm-run05: 來源改變亦同)
453
+ let sigOf = (v) => `${isestr(v.from) ? `x:${v.from}` : 'a'}|${loginIdOf(v)}`
454
+ let loginOld = new Map(ltdtOld.filter((v) => v !== null && typeof v === 'object' && isestr(v.id)).map((v) => [v.id, sigOf(v)]))
455
+ let dClash = findLoginIdClashDetail(rows, (r) => !(isestr(r.id) && loginOld.has(r.id) && loginOld.get(r.id) === sigOf(r)))
456
+ if (dClash !== null) {
457
+ srLog.error({ event: 'updateTabItems-ckKey-error', keyTable, key: 'id', msg: 'loginIdCollision', reason: (dClash.kind === 'otherFrom') ? 'srcKeyOtherFrom' : ((dClash.kind === 'sameFrom') ? 'srcKeyClaimTwice' : 'adminIdIsSrcKey'), row: dClash.k })
458
+ return Promise.reject('loginIdCollision')
459
+ }
460
+ }
461
+
304
462
  //寫入前驗證(規則帳本 R-ORM-01, ADR-026): 於第一個破壞步驟(del)之前擋下可預判之寫入失敗, 被拒時資料表零變更.
305
463
  //why: 寫入順序為 del → insert → save, 而 w-serv-orm 1.1.8 起對「會被 clearXSS 改寫之主鍵」整批拒絕、LMDB 對 > 1978 B 之主鍵整批拒絕,
306
464
  //修正前遇之即「舊列已刪、新列被拒」(改標的 id 時該列遺失, 2026-10-14 V3/V7 實測); 非主鍵欄位之標記則被靜默改寫(存值≠送入值).
@@ -319,11 +477,13 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
319
477
  }
320
478
 
321
479
  //writeMain
480
+ //修改只改不增、刪除未命中回報(規格第 6 節; R-ONLYMOD-01): del/save 帶 opt { mustExist: true }(伺服端自組, 不轉傳客戶端之任何參數), 列已不存在(同鎖內新鮮讀取之後仍不在, 理論上不會發生)→ 'rowNotFound' 零寫入、不復活;
481
+ //insert 為新增(主鍵已存在即略過之冪等新增, 不比對、不去重, 資料規則要求唯一者照其規則擋)
322
482
  let writeMain = async () => {
323
483
 
324
484
  //del
325
485
  if (size(r.del) > 0) {
326
- await procOrm(userId, keyTable, 'del', r.del) //須使用procOrm才有辦法自動給予相關欄位
486
+ await procOrm(userId, keyTable, 'del', r.del, { mustExist: true }) //須使用procOrm才有辦法自動給予相關欄位
327
487
  // .catch((err) => {
328
488
  // console.log('woItems[keyTable].del err', err)
329
489
  // })
@@ -339,7 +499,7 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
339
499
 
340
500
  //diff
341
501
  if (size(r.diff) > 0) {
342
- await procOrm(userId, keyTable, 'save', r.diff) //須使用procOrm才有辦法自動給予相關欄位
502
+ await procOrm(userId, keyTable, 'save', r.diff, { mustExist: true }) //須使用procOrm才有辦法自動給予相關欄位
343
503
  // .catch((err) => {
344
504
  // console.log('woItems[keyTable].save err', err)
345
505
  // })
@@ -347,6 +507,27 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
347
507
 
348
508
  }
349
509
 
510
+ //readAfter: 存檔成功後於同一表鎖內(含引用端鎖)新鮮讀回該表全部列(opt.returnRows 時; 規格第 8 節), 前端立即以之更新畫面與基準; 寫入已生效, 讀回失敗不使存檔失敗(回 null, 前端退回等推播), 失敗入 srLog
511
+ //holder 給了(引用連帶有改寫引用端表)時一併讀回引用端表(others[引用端表名])——該表亦被本次寫入改動, 前端同樣立即更新其基準, 免自己存檔後馬上去存引用端頁而被自己上一次之寫入誤擋
512
+ let readAfter = async (holder = null) => {
513
+ if (!returnRows) {
514
+ return null
515
+ }
516
+ try {
517
+ let rs = await woItems[keyTable].select()
518
+ let out = { rows: sortBy(rs, 'order'), others: {} }
519
+ if (holder !== null) {
520
+ let rh = await woItems[holder].select()
521
+ out.others[holder] = sortBy(rh, 'order')
522
+ }
523
+ return out
524
+ }
525
+ catch (err) {
526
+ srLog.error({ event: 'updateTabItems-readBack-error', keyTable, userId, msg: 'getDataError', raw: (typeof err === 'string') ? err : ((err && err.message) || String(err)) })
527
+ return null
528
+ }
529
+ }
530
+
350
531
  //引用連帶(R-PRINCIPLE-00 原則 6、適用表 m/n, 規則帳本 R-REF-CASCADE-01, ADR-036): grups/pemis 改名(同 id 異名)與 grups/pemis/targets 刪除(舊 id 不在送入整表),
351
532
  //須於同一次寫入內連帶更新/清理引用它之授權字串(users.cgrups、grups.cpemis、pemis.crules). 是否須改寫引用端只依本表新舊列判定(diffRefTargets), 故於持有本表鎖之下即可決定是否再取引用端表之鎖;
352
533
  //取齊鎖之後才讀取引用端、規劃與預檢(新名撞懸空名、歷史同名組改名、容器內相撞、改寫後欄位驗證), 預檢與寫入涵蓋同一版本(外審 A.3), 被拒時資料表零變更.
@@ -356,7 +537,7 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
356
537
  let kind = arrHas(keyTable, ['targets', 'pemis', 'grups']) ? diffRefTargets({ keyTable, rowsOld: ltdtOld, rowsNew: rowsPlan, opt: { cascadeRename } }) : null
357
538
  if (kind !== null && (kind.renames.length > 0 || kind.delNames.length > 0)) {
358
539
  let holder = REF_OF[keyTable].holder
359
- return await withTabLocks(kmx, [holder], async () => {
540
+ return await withTabLocks(lockTabs, [holder], async () => {
360
541
  let holdersOld = await woItems[holder].select()
361
542
  let plan = planRefChanges({ keyTable, rowsOld: ltdtOld, rowsNew: rowsPlan, holdersOld, opt: { cascadeRename } })
362
543
  let errPlan = plan.err !== null ? plan.err : checkCascadePlan(plan)
@@ -365,15 +546,16 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
365
546
  return Promise.reject(errPlan)
366
547
  }
367
548
  await writeWithCascade({ plan, holdersOld, woItems, procOrm, userId, srLog, writeMain })
368
- return ltdtNew
549
+ return await readAfter(plan.changes.length > 0 ? holder : null)
369
550
  }, [keyTable])
370
551
  }
371
552
 
372
553
  await writeMain()
373
554
 
374
- return ltdtNew
555
+ return await readAfter()
375
556
 
376
- }))
557
+ },
558
+ }).catch(mapGuardErr(keyTable, userId))))
377
559
  }
378
560
 
379
561
 
@@ -389,48 +571,62 @@ function proc(woItems, procOrm, { srLog, kmx, lockSave, mappingBy = 'id', cascad
389
571
  }
390
572
 
391
573
 
574
+ //outOf: 成功回傳. 預設為成功 msg key 字串(既有呼叫端不變); 客戶端帶 returnRows:true 且讀回成功時改回 { msg, rows }(rows=寫入後該表全部列, 鎖內新鮮讀取; 規格第 8 節)
575
+ // others: 引用連帶有改寫引用端表時, 引用端表之寫入後全部列(鍵為表名); 無則為空物件
576
+ let outOf = (after, opt) => {
577
+ return (get(opt, 'returnRows', false) === true && after !== null && typeof after === 'object' && Array.isArray(after.rows)) ? { msg: 'saveTabItemsSuccess', rows: after.rows, others: after.others } : 'saveTabItemsSuccess'
578
+ }
579
+
580
+
392
581
  //updateTargets
393
582
  let updateTargets = async (userId, rows, opt = {}) => {
583
+ let rowsAfter = null
584
+ let optc = pickClientOpt(opt)
394
585
  try {
395
- await updateTabItems('targets', userId, rows, 'id', pickClientOpt(opt))
586
+ rowsAfter = await updateTabItems('targets', userId, rows, 'id', optc)
396
587
  }
397
588
  catch (err) { throw errLog('updateTargets-error', userId, err) }
398
589
  srLog.info({ event: 'updateTargets-success', userId })
399
- return 'saveTabItemsSuccess' //成功 msg key
590
+ return outOf(rowsAfter, optc) //成功 msg key
400
591
  }
401
592
 
402
593
 
403
594
  //updatePemis
404
595
  let updatePemis = async (userId, rows, opt = {}) => {
596
+ let rowsAfter = null
597
+ let optc = pickClientOpt(opt)
405
598
  try {
406
- await updateTabItems('pemis', userId, rows, 'name', pickClientOpt(opt))
599
+ rowsAfter = await updateTabItems('pemis', userId, rows, 'name', optc)
407
600
  }
408
601
  catch (err) { throw errLog('updatePemis-error', userId, err) }
409
602
  srLog.info({ event: 'updatePemis-success', userId })
410
- return 'saveTabItemsSuccess' //成功 msg key
603
+ return outOf(rowsAfter, optc) //成功 msg key
411
604
  }
412
605
 
413
606
 
414
607
  //updateGrups
415
608
  let updateGrups = async (userId, rows, opt = {}) => {
609
+ let rowsAfter = null
610
+ let optc = pickClientOpt(opt)
416
611
  try {
417
- await updateTabItems('grups', userId, rows, 'name', pickClientOpt(opt))
612
+ rowsAfter = await updateTabItems('grups', userId, rows, 'name', optc)
418
613
  }
419
614
  catch (err) { throw errLog('updateGrups-error', userId, err) }
420
615
  srLog.info({ event: 'updateGrups-success', userId })
421
- return 'saveTabItemsSuccess' //成功 msg key
616
+ return outOf(rowsAfter, optc) //成功 msg key
422
617
  }
423
618
 
424
619
 
425
620
  //updateUsers, opt.pickKeysOnly 給 ['cgrups'] 時僅採納既有使用者之群組指派變更(modeEditUsers='for:grups'; 伺服端設定, WWebPerm 組入, 不接受用戶端給);
426
- //opt.baseline 為草稿載入基準(用戶端給, 其餘 opt 鍵不採納, 見 pickClientOpt 與 WWebPerm 之 kpFunExt)
621
+ //opt.baseline/opt.returnRows 為用戶端給(其餘 opt 鍵不採納, 見 pickClientOpt 與 WWebPerm 之 kpFunExt)
427
622
  let updateUsers = async (userId, rows, opt = {}) => {
623
+ let rowsAfter = null
428
624
  try {
429
- await updateTabItems('users', userId, rows, 'id', opt)
625
+ rowsAfter = await updateTabItems('users', userId, rows, 'id', opt)
430
626
  }
431
627
  catch (err) { throw errLog('updateUsers-error', userId, err) }
432
628
  srLog.info({ event: 'updateUsers-success', userId })
433
- return 'saveTabItemsSuccess' //成功 msg key
629
+ return outOf(rowsAfter, opt) //成功 msg key
434
630
  }
435
631
 
436
632
 
@@ -456,7 +652,7 @@ function toOutKey(err, keyFail = 'saveWriteFail') {
456
652
  }
457
653
 
458
654
 
459
- export { mergePickKeysOnly, toOutKey, isRowObj }
655
+ export { mergePickKeysOnly, toOutKey, isRowObj, pickClientOpt }
460
656
 
461
657
 
462
658
  export default proc
@@ -25,6 +25,11 @@ let kpLang = {
25
25
  eng: 'Login denied',
26
26
  cht: '拒絕登入',
27
27
  },
28
+ //perm-run01(R-ADMIN-ONLY-01): 後台登入檢查回 userNotAdmin(登入者不是系統管理員, 即使部署方放行)時前端顯示之連線狀態文字; 同鍵另置於 src/plugins/mUI.mjs 之 kpFallback(被拒者連不上資料通道, 取不到本字典)
29
+ csErrNotAdmin: {
30
+ eng: 'No permission to enter the permission console',
31
+ cht: '無權進入權限後台',
32
+ },
28
33
 
29
34
  aggridLanguage: {
30
35
  eng: 'en',
@@ -315,7 +320,7 @@ let kpLang = {
315
320
  //@@UI1-KEYS
316
321
  //F-23/F-26 清單頁(標的/權限/群組/使用者)之外部列與外部同步提示(R-PRINCIPLE-00 原則 3「收嚴+提示」、原則 5; R-EXT-OWN-01; ADR-037; src/js/externalUi.mjs、src/js/getAddImpact.mjs):
317
322
  // externalRowLocked 外部列首欄鎖頭圖示之 tooltip({from} 為來源); externalRowsNotice/externalRowsNoticeTarget 清單上方提示({n} 為外部列數; 標的無指派欄故另一鍵);
318
- // syncWarnIdRenumber 外部同步疑似 id 重編之警示({from} 來源、{n} 筆數); externalRowDelBlocked 勾選含外部列而被略過之提示({n}); nameUsedByExternal 後台列與外部列撞名之儲存前提示({name}、{from});
323
+ // syncWarnIdRenumber 外部同步疑似來源鍵(srcKey)重編之警示({from} 來源、{n} 筆數; 口徑:同名異 srcKey, perm-run04 起文案同此口徑); externalRowDelBlocked 勾選含外部列而被略過之提示({n}); nameUsedByExternal 後台列與外部列撞名之儲存前提示({name}、{from});
319
324
  // confirmAddRefs* 新增撞「懸空名」(仍被啟用中之引用指向、查無實體)之確認: {list} 新名稱(至多 5 個)、{n} 受影響之引用端數、{m} 其中以 AND 引用者(標的之 crules 項目無 mode, 無 AND 句)
320
325
  externalRowLocked: {
321
326
  eng: `Synchronized from external source "{from}". Read-only here.`,
@@ -329,9 +334,9 @@ let kpLang = {
329
334
  eng: `{n} row(s) are synchronized from an external source. They are read-only and cannot be deleted here.`,
330
335
  cht: `有 {n} 筆資料由外部來源同步,於此為唯讀且不可刪除。`,
331
336
  },
332
- syncWarnIdRenumber: {
333
- eng: `The external source "{from}" appears to have renumbered ids ({n} item(s) now carry a name that previously belonged to an item with a different id). Each synchronization removes the old items and cleans up the references to them, which may erase the assignments made here. Please ask the source to keep ids stable or to omit ids.`,
334
- cht: `外部來源「{from}」疑似重編 id(有 {n} 筆項目的名稱,先前屬於不同 id 之項目)。每次同步會移除舊項目並連帶清理其引用,可能抹掉在此做的指派。請來源端保持 id 穩定或不帶 id。`,
337
+ syncWarnIdRenumber: { //perm-run04 起口徑為「同名異來源鍵(srcKey)」(外部匯入依 srcKey 差異同步, 外部端不得送 id); 鍵名沿用以免動前端接線
338
+ eng: `The external source "{from}" appears to have renumbered its source keys ({n} item(s) now carry a name that previously belonged to an item with a different source key). Each synchronization removes the old items and cleans up the references to them, which may erase the assignments made here. Please ask the source to keep the srcKey of each item stable.`,
339
+ cht: `外部來源「{from}」疑似重編來源鍵(有 {n} 筆項目的名稱,先前屬於不同來源鍵之項目)。每次同步會移除舊項目並連帶清理其引用,可能抹掉在此做的指派。請來源端保持來源鍵(srcKey)穩定。`,
335
340
  },
336
341
  externalRowDelBlocked: {
337
342
  eng: `{n} selected row(s) are synchronized from an external source and cannot be deleted here. They were skipped.`,
@@ -435,6 +440,75 @@ let kpLang = {
435
440
  eng: `Not saved: changing the assignment settings of rows synchronized from an external source requires the latest loaded data, which this request did not carry. Nothing was changed. Please reload the page and try again.`,
436
441
  cht: `未儲存:修改外部來源同步列之授權指派設定須附載入時之資料版本,此次請求未附。資料未變更,請重新整理頁面後再操作。`,
437
442
  },
443
+ //perm-run01(多人同時編輯共用核心; 規則帳本 R-BASELINE-01、R-SUBJECT-ID-01; ADR-037 Update):
444
+ // saveBaselineFormat 基準格式或版本不識(升級前開啟之舊頁面所帶之套內舊版指紋, 或損壞之基準)→ 拒收、零寫入、提示重新載入(不降級為不帶基準; 原則 3);
445
+ // relSubjectChangedGrup/relSubjectChangedPemi 反向關聯對話框存檔時, 主體(群組/權限)已被刪除、改名或刪除後以同名重建(id 不同)→ 拒收、零寫入、提示重新載入(原則 3、6; 引用以名稱為準, 主體身分變了即引用會指向另一個實體); {name} 為開框時之主體名稱
446
+ saveBaselineFormat: {
447
+ eng: `Not saved: this page was opened with a different version of the system and its data version cannot be recognized. Nothing was changed. Please reload the page and try again.`,
448
+ cht: `未儲存:此頁面是以不同版本載入,其資料版本無法辨識。資料未變更,請重新整理頁面後再操作。`,
449
+ },
450
+ relSubjectChangedGrup: {
451
+ eng: `Not saved: the group "{name}" this window was opened for has been deleted, renamed or replaced by another group since then. Nothing was changed. Please close this window, reload the page and try again.`,
452
+ cht: `未儲存:本視窗所編輯的群組「{name}」自開啟後已被刪除、改名或被另一個群組取代。資料未變更,請關閉本視窗並重新整理頁面後再操作。`,
453
+ },
454
+ relSubjectChangedPemi: {
455
+ eng: `Not saved: the permission "{name}" this window was opened for has been deleted, renamed or replaced by another permission since then. Nothing was changed. Please close this window, reload the page and try again.`,
456
+ cht: `未儲存:本視窗所編輯的權限「{name}」自開啟後已被刪除、改名或被另一個權限取代。資料未變更,請關閉本視窗並重新整理頁面後再操作。`,
457
+ },
458
+ //perm-run02 範圍一(被引用實體身分; 規則帳本 R-SUBJECT-ID-01、R-REF-IDENTITY-01; ADR-037 Update(perm-run02)):
459
+ // saveSubjectChanged 反向關聯對話框存檔時, 伺服端於引用表鎖內新鮮讀取發現主體(群組/權限)已被刪除、改名或刪除後以同名重建(id 不同)→ 拒收、零寫入、提示重新載入(原則 3、4、6; 前端同判準先擋, 此為伺服端強制之固定訊息, 不含名稱);
460
+ // saveRefChanged 清單頁存檔時, 新增之啟用引用項所指的群組/權限/標的已被刪除、刪除後以同名重建(id 不同)、新增了同名實體, 或引用宣告不完整 → 拒收、零寫入、提示重新載入(不得把授權寫到同名新實體);
461
+ // saveIdentityFormat 身分宣告格式不合或該表無授權引用欄 → 拒收(不降級為不帶宣告)
462
+ saveSubjectChanged: {
463
+ eng: `Not saved: the group or permission this window was opened for has been deleted, renamed or replaced by another one since then. Nothing was changed. Please close this window, reload the page and try again.`,
464
+ cht: `未儲存:本視窗所編輯的群組或權限自開啟後已被刪除、改名或被另一個取代。資料未變更,請關閉本視窗並重新整理頁面後再操作。`,
465
+ },
466
+ saveRefChanged: {
467
+ eng: `Not saved: a group, permission or target you just assigned has been deleted or replaced by another one with the same name since this page was loaded. Nothing was changed. Please reload the page and try again.`,
468
+ cht: `未儲存:您剛指派的群組、權限或標的在載入此頁面後已被刪除,或被同名的另一個取代。資料未變更,請重新整理頁面後再操作。`,
469
+ },
470
+ saveIdentityFormat: {
471
+ eng: `Not saved: the identity information carried by this request is invalid. Nothing was changed. Please reload the page and try again.`,
472
+ cht: `未儲存:此次請求所附之身分資訊無效。資料未變更,請重新整理頁面後再操作。`,
473
+ },
474
+ //perm-run03 範圍一(被引用實體身分宣告改為強制必帶; 使用者 2026-10-23 裁示「這本來就該強制必帶吧? 要不然怎麼確認與驗證調用者身份?」; 規則帳本 R-REF-IDENTITY-01 規則 7、R-SUBJECT-ID-01 規則 5):
475
+ // saveIdentityRequired 存檔改變了引用(新增或移除啟用之引用項、新增列帶引用), 但請求未附(或未附完整)被引用實體之身分宣告 → 拒收、零寫入、提示重新載入(舊頁面或自寫客戶端未附宣告; 固定訊息, 不含名稱)
476
+ saveIdentityRequired: {
477
+ eng: `Not saved: the data was updated or this page is out of date (the request did not carry the identity of the groups, permissions or targets it changes). Nothing was changed. Please reload the page and try again.`,
478
+ cht: `未儲存:資料已更新或頁面過舊(此次請求未附所異動之群組、權限或標的之身分資訊)。資料未變更,請重新整理頁面後再操作。`,
479
+ },
480
+ //perm-run03 範圍四(寫入函數之角色檢查; dspec/使用者身分驗證原則.md 二.4): userNotAdmin 呼叫 updateTargets/updatePemis/updateGrups/updateUsers 者不是 perm users 表中有效之系統管理員(isAdmin='y') → 拒收、零寫入
481
+ //perm-run01(R-ADMIN-ONLY-01): 同鍵亦為資料通道(連線閘、執行身分)與後台登入檢查拒絕非系統管理員之固定鍵, 文字涵蓋存取與變更
482
+ userNotAdmin: {
483
+ eng: `Only system administrators can access or change this data`,
484
+ cht: `僅系統管理員可存取或變更此資料`,
485
+ },
486
+ //perm-run03 範圍二(外部匯入改依來源鍵 srcKey 差異同步; dspec/外部匯入之定義與機制.md 第 9 節):
487
+ // srcKeyInvalid 批次內有列缺 srcKey、srcKey 為空或非字串(含長度/字元不合) → 整批拒收、零寫入; srcKeyDuplicate 同批 srcKey 重複 → 整批拒收;
488
+ // idNotAllowed 批次內有列帶 id(內部主鍵由本系統產生, 外部不得指定) → 整批拒收; srcKeyConflict 同 from 庫內有重複 srcKey(資料異常), 或同一 srcKey 被兩個庫內列主張(一個已有 srcKey、一個以 id 主張)而無法唯一配對 → 整批拒收、零寫入並提示人工處置(以 g_purgeFrom.mjs 清源後重匯入);
489
+ // loginIdCollision(perm-run04, 規則帳本 R-MAPKEY-04/R-MAPKEY-05; 規格第 9 節末 O1): mappingBy='id' 時外部列以 srcKey、後台列以 id 對應外部 token 之使用者 id; 登入識別值於兩列相同即撞值(登入會同時匹配多筆),
490
+ // perm-run05 起「不論 isActive」「含他來源外部列」(後台列 id↔外部列 srcKey、外部列 srcKey↔他來源外部列 srcKey), 訊息須同時涵蓋後台存檔與外部匯入兩種讀者並提示改用可唯一對應之鍵;
491
+ // 匯入批次(整批拒收、零寫入)與後台存檔(整次拒收、零變更)皆回此鍵, 登入時命中多筆另回 ambiguousUserFromPerm; 固定訊息, 不回顯 srcKey 與 id
492
+ srcKeyInvalid: {
493
+ eng: `Invalid srcKey: every row must carry a non-empty string srcKey`,
494
+ cht: `來源鍵 srcKey 無效:每列都須帶非空字串之 srcKey`,
495
+ },
496
+ srcKeyDuplicate: {
497
+ eng: `Duplicate srcKey in the batch`,
498
+ cht: `同一批次內來源鍵 srcKey 重複`,
499
+ },
500
+ idNotAllowed: {
501
+ eng: `The id is generated by this system and must not be provided`,
502
+ cht: `內部主鍵 id 由本系統產生,外部不得提供`,
503
+ },
504
+ srcKeyConflict: {
505
+ eng: `The existing data of this source cannot be matched uniquely by srcKey. Nothing was changed. Please contact the administrator`,
506
+ cht: `此來源之既有資料無法依 srcKey 唯一配對。資料未變更,請聯絡管理員處置`,
507
+ },
508
+ loginIdCollision: {
509
+ eng: `Nothing was changed: a login identifier (the id of an administrator-managed user, or the srcKey of a user synchronized from an external source) is also used by another user or another source, whether or not either user is enabled, so a login by id would match more than one user. Please use a value that identifies the user uniquely, or ask the deployer to map users by another unique key (for example the email)`,
510
+ cht: `資料未變更:有一筆資料的登入識別(後台使用者的 id,或外部同步使用者的 srcKey)與其他使用者或其他來源的相同(不論是否停用),依 id 登入時會同時符合多位使用者。請改用可唯一對應使用者的值,或請部署方改以其他可唯一對應的鍵(例如 email)進行對應`,
511
+ },
438
512
  cannotGetUserId: {
439
513
  eng: `Cannot get the user id`,
440
514
  cht: `無法取得使用者 Id`,