w-web-perm 1.1.12 → 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.
Files changed (124) hide show
  1. package/README.md +158 -123
  2. package/dist/css/app.f09c7433.css +1 -0
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.8af87052.js +1 -0
  5. package/dist/js/app.8af87052.js +1 -0
  6. package/dist/js/{chunk-vendors.3e9e3ef1.js → chunk-vendors-legacy.ece5a361.js} +1 -1
  7. package/dist/js/{chunk-vendors-legacy.3e9e3ef1.js → chunk-vendors.ece5a361.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 +84 -4
  11. package/docs/global.html +23023 -1125
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +232 -32
  14. package/docs/server_cascadeWrite.mjs.html +225 -0
  15. package/docs/server_dataChannel.mjs.html +2 -2
  16. package/docs/server_keyCheck.mjs.html +353 -0
  17. package/docs/server_lockSave.mjs.html +2 -2
  18. package/docs/server_maskLog.mjs.html +2 -2
  19. package/docs/server_procCore.mjs.html +180 -41
  20. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  21. package/docs/server_syncKeep.mjs.html +311 -0
  22. package/docs/server_tools_authRefsAudit.mjs.html +247 -0
  23. package/docs/server_tools_authRefsClean.mjs.html +335 -0
  24. package/docs/server_tools_fromTools.mjs.html +797 -0
  25. package/docs/server_tools_isMain.mjs.html +103 -0
  26. package/docs/server_tools_nameDupReport.mjs.html +280 -0
  27. package/docs/server_validateRows.mjs.html +2 -2
  28. package/docs/server_wrapInjected.mjs.html +3 -3
  29. package/docs/src_fetchJson.mjs.html +2 -2
  30. package/docs/src_js_cvKpToArr.mjs.html +2 -2
  31. package/docs/src_js_cvKpToObj.mjs.html +44 -33
  32. package/docs/src_js_draftSync.mjs.html +142 -0
  33. package/docs/src_js_eachOwn.mjs.html +3 -3
  34. package/docs/src_js_editorRefRows.mjs.html +356 -0
  35. package/docs/src_js_externalRows.mjs.html +338 -0
  36. package/docs/src_js_externalUi.mjs.html +230 -0
  37. package/docs/src_js_findRefRows.mjs.html +107 -0
  38. package/docs/src_js_findTouchedDup.mjs.html +150 -0
  39. package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
  40. package/docs/src_js_fromAudit.mjs.html +489 -0
  41. package/docs/src_js_getAddImpact.mjs.html +269 -0
  42. package/docs/src_js_getAuthWarn.mjs.html +463 -0
  43. package/docs/src_js_getCascadePreview.mjs.html +258 -0
  44. package/docs/src_js_getDelRefCount.mjs.html +210 -0
  45. package/docs/src_js_getEffMode.mjs.html +92 -0
  46. package/docs/src_js_getKpLossy.mjs.html +47 -52
  47. package/docs/src_js_getNameErrs.mjs.html +266 -0
  48. package/docs/src_js_getOwn.mjs.html +3 -3
  49. package/docs/src_js_getPropKey.mjs.html +109 -0
  50. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  51. package/docs/src_js_isAuthMode.mjs.html +4 -4
  52. package/docs/src_js_joinNames.mjs.html +2 -2
  53. package/docs/src_js_legendFit.mjs.html +2 -2
  54. package/docs/src_js_pairRowsToSnapshot.mjs.html +156 -0
  55. package/docs/src_js_refAudit.mjs.html +359 -0
  56. package/docs/src_js_refCascade.mjs.html +572 -0
  57. package/docs/src_js_relDialogSend.mjs.html +199 -0
  58. package/docs/src_js_rowDigest.mjs.html +265 -0
  59. package/docs/src_plugins_dataReloadGuard.mjs.html +109 -0
  60. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  61. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  62. package/g_authRefsClean.mjs +79 -0
  63. package/g_fixFromType.mjs +92 -0
  64. package/g_initialData.mjs +3 -3
  65. package/g_purgeFrom.mjs +127 -0
  66. package/package.json +1 -1
  67. package/server/WWebPerm.mjs +230 -30
  68. package/server/cascadeWrite.mjs +153 -0
  69. package/server/keyCheck.mjs +281 -0
  70. package/server/procCore.mjs +178 -39
  71. package/server/procLang.mjs +270 -3
  72. package/server/syncKeep.mjs +239 -0
  73. package/server/tools/authRefsAudit.mjs +175 -0
  74. package/server/tools/authRefsClean.mjs +263 -0
  75. package/server/tools/fromTools.mjs +725 -0
  76. package/server/tools/isMain.mjs +31 -0
  77. package/server/tools/nameDupReport.mjs +208 -0
  78. package/server/wrapInjected.mjs +1 -1
  79. package/src/components/LayoutContentGrups.vue +396 -75
  80. package/src/components/LayoutContentPemis.vue +395 -75
  81. package/src/components/LayoutContentTargets.vue +261 -25
  82. package/src/components/LayoutContentUsers.vue +334 -97
  83. package/src/components/VeCgrups.vue +88 -129
  84. package/src/components/VeCpemis.vue +86 -129
  85. package/src/components/VeCrules.vue +15 -5
  86. package/src/components/VeGrupBlngUsers.vue +202 -89
  87. package/src/components/VePemiBlngGrups.vue +209 -99
  88. package/src/js/cvKpToObj.mjs +42 -31
  89. package/src/js/draftSync.mjs +70 -0
  90. package/src/js/eachOwn.mjs +1 -1
  91. package/src/js/editorRefRows.mjs +284 -0
  92. package/src/js/externalRows.mjs +266 -0
  93. package/src/js/externalUi.mjs +158 -0
  94. package/src/js/findRefRows.mjs +35 -0
  95. package/src/js/findTouchedDup.mjs +78 -0
  96. package/src/js/fromAudit.mjs +417 -0
  97. package/src/js/getAddImpact.mjs +197 -0
  98. package/src/js/getAuthWarn.mjs +391 -0
  99. package/src/js/getCascadePreview.mjs +186 -0
  100. package/src/js/getDelRefCount.mjs +138 -0
  101. package/src/js/getEffMode.mjs +20 -0
  102. package/src/js/getKpLossy.mjs +44 -49
  103. package/src/js/getNameErrs.mjs +194 -0
  104. package/src/js/getOwn.mjs +1 -1
  105. package/src/js/getPropKey.mjs +37 -0
  106. package/src/js/isAuthMode.mjs +2 -2
  107. package/src/js/pairRowsToSnapshot.mjs +84 -0
  108. package/src/js/refAudit.mjs +287 -0
  109. package/src/js/refCascade.mjs +500 -0
  110. package/src/js/relDialogSend.mjs +127 -0
  111. package/src/js/rowDigest.mjs +193 -0
  112. package/src/plugins/dataReloadGuard.mjs +37 -0
  113. package/src/plugins/mShare.mjs +144 -140
  114. package/src/plugins/mUI.mjs +3 -8
  115. package/src/schema/tables/users.mjs +152 -152
  116. package/dist/css/app.6918ec45.css +0 -1
  117. package/dist/js/app-legacy.7cb7e394.js +0 -1
  118. package/dist/js/app.7cb7e394.js +0 -1
  119. package/docs/src_js_getKeyOrderLost.mjs.html +0 -121
  120. package/docs/src_js_getKpWriteLost.mjs.html +0 -100
  121. package/docs/src_js_keepKeyOrder.mjs.html +0 -139
  122. package/src/js/getKeyOrderLost.mjs +0 -49
  123. package/src/js/getKpWriteLost.mjs +0 -28
  124. package/src/js/keepKeyOrder.mjs +0 -67
@@ -37,7 +37,11 @@ import procStaInfor from './procStaInfor.mjs'
37
37
  import { maskTok, maskQuery, maskUrl } from './maskLog.mjs'
38
38
  import wrapInjected from './wrapInjected.mjs'
39
39
  import validateRowsBeforeWrite from './validateRows.mjs'
40
+ import planRefChanges, { REF_OF, diffRefTargets } from '../src/js/refCascade.mjs'
41
+ import { findDupIndex, findKeyOtherFrom, withTabLocks, mapKeyOf, checkMapKeyBatch, pickUniqueUser } from './keyCheck.mjs'
42
+ import { checkCascadePlan, writeWithCascade, buildSyncRowsNew } from './cascadeWrite.mjs'
40
43
  import { createAuthorizeExec, wrapOperOrm } from './dataChannel.mjs'
44
+ import { markHasKey, keepAssignOnMissing, keepAssignByEntity, countIdRenumber } from './syncKeep.mjs'
41
45
 
42
46
 
43
47
  /**
@@ -57,7 +61,9 @@ import { createAuthorizeExec, wrapOperOrm } from './dataChannel.mjs'
57
61
  * @param {String} [opt.webLogo=''] 輸入站台logo字串,採base64格式,預設''
58
62
  * @param {String} [opt.subfolder=''] 輸入站台所在子目錄字串,提供站台位於內網採反向代理進行服務時,故需支援位於子目錄情形,預設''
59
63
  * @param {String} [opt.urlRedirect=''] 輸入錯誤時自動轉址字串,提供站台例如無法登入或驗證失敗時須自動轉址,預設''
60
- * @param {String} [opt.mappingBy='id'] 輸入外部系統識別使用者token後所提供之資料物件,與權限系統之使用者資料物件,兩者間查找之對應欄位,可選'id'、'email'、'name',未給或非此三者時為'id',預設'id'
64
+ * @param {String} [opt.mappingBy='id'] 輸入外部系統識別使用者token後所提供之資料物件,與權限系統之使用者資料物件,兩者間查找之對應欄位,可選'id'、'email'、'name',未給或非此三者時為'id',預設'id'。對應鍵須能唯一指出使用者:mappingBy為'email'或'name'時,外部同步(syncAndReplaceTabs)users之有效(isActive為'y')列,該欄位須為非空字串且與本批及其他from之有效列皆不重複(違規整批拒收invalidRows);登入查找以對應鍵於權限系統users命中多於1筆有效使用者時一律拒絕(ambiguousUserFromPerm),不取首筆;mappingBy為'id'(預設)時不另檢查
65
+ * @param {Boolean} [opt.cascadeRename=true] 輸入群組、權限改名時是否連帶更新引用它之授權字串(users.cgrups、grups.cpemis),預設true。改名以主鍵id判定(同id異名),後台整表儲存與外部同步(同一from內)皆適用,於同一次寫入內改寫並可能寫入外部來源之users列之cgrups;給false則不連帶(引用成為懸空,視同引用查無對象而收嚴並於頁面提示)。群組、權限、標的被刪除時之引用連帶清理(users.cgrups、grups.cpemis、pemis.crules)不受此設定影響
66
+ * @param {String} [opt.lockExternalRows='y'] 輸入後台是否不得改外部列(from為非空字串,即經syncAndReplaceTabs由外部來源同步之列),可選'y'或'n',預設'y'。'y'時伺服端強制:後台新增列from須為空字串、外部列不可刪除、不可改from、身分/結構欄(指派欄與順序以外之欄位)不可改,違規整次拒收(saveFromNotAllowed/saveExternalRowChanged);授權指派欄(users.cgrups、grups.cpemis、pemis.crules)後台可寫,但須附載入基準(用戶端自動附帶),無基準者拒收(saveBaselineRequired);外部列之order由外部擁有、後台儲存不重編。給'n'回到舊行為(僅保留from須為字串之檢查)。升版後既有庫中from為非空字串之列即視為外部列而唯讀,請先以g_fixFromType.mjs/g_purgeFrom.mjs盤點(見README Upgrade Notes)
61
67
  * @param {String} [opt.logFd='./logs'] 輸入log紀錄儲存位置字串,預設'./logs'
62
68
  * @param {String} [opt.logInterval='hr'] 輸入log紀錄檔案拆檔時長字串,可選'hr'或'day',預設'hr'
63
69
  * @param {Integer} [opt.logNumKeep=null] 輸入log檔保留數上限正整數,超出者自最舊刪除,未給採w-syslog預設(hr模式365*24、day模式365),預設null
@@ -250,6 +256,14 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
250
256
  // console.log('mappingBy', mappingBy)
251
257
 
252
258
 
259
+ //cascadeRename, 群組/權限改名時是否連帶更新引用它之授權字串(預設 true; 僅 false 關閉; 刪除連帶清理不受此設定影響, R-REF-CASCADE-01)
260
+ let cascadeRename = get(opt, 'cascadeRename', true) !== false
261
+
262
+
263
+ //lockExternalRows, 後台是否不得改外部列(from 為非空字串者)之身分/結構欄、不得刪除與複製繼承 from(預設 true; 僅 'n' 或 false 關閉, 回到舊行為; F-23, R-EXT-OWN-01, ADR-037)
264
+ let lockExternalRows = !(get(opt, 'lockExternalRows', 'y') === 'n' || get(opt, 'lockExternalRows', 'y') === false)
265
+
266
+
253
267
  //kpLangExt
254
268
  let kpLangExt = get(opt, 'kpLangExt', null)
255
269
 
@@ -280,7 +294,7 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
280
294
  //部署方常以 w-web-sso 對外 helper(≤1.1.4)包裝 getUserByToken, 其 reject 字串含已代入系統介接權杖與使用者權杖之完整網址.
281
295
  //包裝後失敗視同否定結果(getUserByToken → null 等同查無, verify* → false 等同無權限), 走各呼叫點既有分支回自家 key;
282
296
  //上游原文不回前端、不入 log, 只於此記 warn(白名單 key, 或型別與 Error.name). 同一次失敗 log 2~3 筆: 此處 warn(因) + 呼叫點 error(果).
283
- //以下取代後, 本函數內除此三行外不得再直接使用原始參數(盤點見 CLAUDE_rulebook.md「權杖不外洩」, 測試 unit-wrapInjected WRAP-SITE-*)
297
+ //以下取代後, 本函數內除此三行外不得再直接使用原始參數(盤點見 sgud/rule/rulebook.md「權杖不外洩」, 測試 unit-wrapInjected WRAP-SITE-*)
284
298
  let logInjectFail = (name) => {
285
299
  return (d) => {
286
300
  srLog.warn({ event: `inject-${name}-fail`, ...d })
@@ -305,10 +319,25 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
305
319
 
306
320
 
307
321
  //pmKeyMutex: per-key in-memory mutex, 同 key 序列化、不同 key 並行.
308
- //perm 為「整表批次 diff」寫入, key 粒度為 {op}:{keyTable}, 防同表並行 race (lost update).
322
+ //perm 為「整表批次 diff」寫入, key 粒度為整表: tabWriteKey(keyTable)(= 'tabWrite:' + keyTable), 外部同步與後台整表寫入共用同鍵, 防同表並行 race (lost update) 與唯一性檢查被交錯略過 (R-WRITE-LOCK-01).
309
323
  let kmx = pmKeyMutex()
310
324
 
311
325
 
326
+ //syncWarns: 外部同步「同一 from 同名異 id」(疑似 id 重編)之提示登錄(記憶體, 重啟即清; 主控定案: 不加開關, 記 warn log 並使頁面可提示):
327
+ //以「表名+來源」為鍵, 最近一次同步有此情形者登錄筆數與時間, 已無此情形者清除; 只含表名、來源名稱、筆數、時間, 不含個資
328
+ let kpSyncWarn = new Map()
329
+ let setSyncWarn = (keyTable, from, n) => {
330
+ let k = `${keyTable}\u0000${from}`
331
+ if (n > 0) {
332
+ kpSyncWarn.set(k, { keyTable, from, n, time: new Date().toISOString() })
333
+ }
334
+ else {
335
+ kpSyncWarn.delete(k)
336
+ }
337
+ }
338
+ let getSyncWarns = () => [...kpSyncWarn.values()]
339
+
340
+
312
341
  //lockSave: 後台整表寫入(updateTabItems)之雙擊防護(後端, 見 lockSave.mjs, ADR-025): 以「updateTabItems:{表名}:{操作者 id}」於 cacheSt 原子占位,
313
342
  //同一操作者之同表寫入處理中再送出即 reject 'saveInProgress'(不排隊); 包在 kmx 之外層, 不同操作者之並行仍由 kmx 序列化(ADR-012)
314
343
  let cst = cacheSt()
@@ -316,7 +345,7 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
316
345
 
317
346
 
318
347
  //procCore: 整表批次 diff 寫入邏輯 (updateTabItems + updateTargets/Pemis/Grups/Users)
319
- let pc = procCore(woItems, procOrm, { srLog, kmx, lockSave })
348
+ let pc = procCore(woItems, procOrm, { srLog, kmx, lockSave, mappingBy, cascadeRename, lockExternalRows }) //mappingBy 供 users 整表寫入的對應鍵檢查(R-PRINCIPLE-00 適用表 h); cascadeRename 供群組/權限改名之引用級聯(適用表 m); lockExternalRows 供後台對外部列之守門(F-23)
320
349
 
321
350
 
322
351
  //procStaInfor: 統計資訊, fdLog 對齊 srLog 使用同一目錄(讀 settings.logFd, fallback 邏輯同 srLog.mjs); mock 由 settings.staEventMock 注入(e2e 統計圖穩定用)
@@ -353,6 +382,10 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
353
382
  modeEditGrups,
354
383
  modeEditUsers,
355
384
 
385
+ cascadeRename, //改名是否連帶更新引用(opt.cascadeRename), 供清單頁儲存前預覽與伺服端一致(R-REF-CASCADE-01)
386
+ lockExternalRows: lockExternalRows ? 'y' : 'n', //後台是否不得改外部列(opt.lockExternalRows), 供清單頁逐列唯讀、複製重置 from 與伺服端一致(R-EXT-OWN-01)
387
+ syncWarns: getSyncWarns(), //外部同步疑似 id 重編之提示(同一 from 同名異 id), 供清單頁橫幅(原則 3: 提示); 不含個資, 僅表名、來源名稱、筆數、時間
388
+
356
389
  widthTargetId,
357
390
  widthTargetDescription,
358
391
  widthPemisName,
@@ -434,11 +467,18 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
434
467
  return Promise.reject(`cannotGetUserProp`)
435
468
  }
436
469
 
437
- //userFind
438
- let userFind = await woItems.users.select({ [mappingBy]: vSelf, isActive: 'y' })
439
- userFind = get(userFind, 0, null)
470
+ //userFind, 對應鍵須唯一(F-14, 規則帳本 R-MAPKEY-02): 命中多於 1 筆即拒絕, 不取首筆(取首筆等於由資料庫列序決定登入者為誰)
471
+ let userRs = await woItems.users.select({ [mappingBy]: vSelf, isActive: 'y' })
472
+ let { user: userFind, count: countFind } = pickUniqueUser(userRs)
440
473
  // console.log('userFind', userFind)
441
474
 
475
+ //check, 多筆: 只記 mappingBy 與筆數, 不記使用者之對應值(email/name 為個資); 0 筆行為不變
476
+ if (countFind > 1) {
477
+ console.log('mappingBy', mappingBy, 'count', countFind)
478
+ srLog.error({ event: 'getTokenUser-error', msg: 'ambiguousUserFromPerm', mappingBy, count: countFind })
479
+ return Promise.reject(`ambiguousUserFromPerm`)
480
+ }
481
+
442
482
  //check
443
483
  if (!iseobj(userFind)) {
444
484
  console.log('userSelf', userSelf)
@@ -589,7 +629,9 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
589
629
  let syncAndReplaceTabs = async (userId, inp, keyTable) => {
590
630
 
591
631
  //序列化同表之並行整表 delAll+insert: 防 lost update (同 keyTable 序列化, 不同表並行)
592
- return await kmx('syncAndReplaceTabs:' + keyTable, async () => {
632
+ //與後台整表寫入(procCore.updateTabItems)共用同表互斥鍵 tabWriteKey: 兩者各自先檢查名稱/對應鍵唯一後寫入, 鍵不同則交錯可略過 F-02/F-14(外審 sol R10);
633
+ //有改名或刪除而須改寫引用端時, 於內層依階層再取引用端表之鎖(同步群組/權限/標的時, 被刪除或改名者之引用須連帶清理/更新, R-REF-CASCADE-01), 皆經單一取鎖函數(R-WRITE-LOCK-02)
634
+ return await withTabLocks(kmx, [keyTable], async () => {
593
635
 
594
636
  //from
595
637
  let from = get(inp, 'from', '')
@@ -622,6 +664,11 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
622
664
  return Promise.reject(`invalidRows`)
623
665
  }
624
666
 
667
+ //keyAssign/hasAssign: 後台可寫之指派欄(users.cgrups/grups.cpemis/pemis.crules; targets 無)及各列是否自帶該鍵(須於 ltdtmapping 缺鍵補 '' 之前記錄);
668
+ //缺該鍵者於下方保留庫內指派(F-23, 指派欄屬後台, 見 syncKeep.mjs)
669
+ let keyAssign = (keyTable === 'users') ? 'cgrups' : ((keyTable === 'grups') ? 'cpemis' : ((keyTable === 'pemis') ? 'crules' : null))
670
+ let hasAssign = (keyAssign !== null) ? markHasKey(rows, keyAssign) : null
671
+
625
672
  //save from
626
673
  rows = map(rows, (v, k) => {
627
674
  // v.order = k + 1 //order大部分由外部給予, 不能複寫
@@ -658,6 +705,27 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
658
705
  }
659
706
  }
660
707
 
708
+ //群組/權限名稱唯一(F-02, 規則帳本 R-NAME-UNIQ-01, ADR-034), 本批 rows 內 name 精確 === 重複整批拒收(他 from 已使用者見下方 nameOtherFrom), 於 delAll 之前, 該 from 與他 from 之既有列不變.
709
+ //why: 授權以名稱查找(arrFinder 取第一個同名列), 同名並存即授權取決於資料庫列序; 後台 ckKey/updateTabItems 早已拒同名, 外部同步原不拒(ADR-031 決定 4 曾明載不比照), 兩入口不對稱.
710
+ //外部應用只收固定語系鍵 invalidRows(規則帳本 R-ORM-02); targets 之 id 為主鍵(validateRowsBeforeWrite 已查批內重複), users 之 name 允許重複, 皆不在此
711
+ if (keyName === 'name') {
712
+ let kDup = findDupIndex(rows, 'name')
713
+ if (kDup >= 0) {
714
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: 'nameDuplicate', keyTable, from, row: kDup, field: 'name' })
715
+ return Promise.reject(`invalidRows`)
716
+ }
717
+ }
718
+
719
+ //users 對應鍵批內檢查(F-14, 規則帳本 R-MAPKEY-01, ADR-034): mappingBy 為 'email'/'name' 時, 每個 isActive==='y' 之列該欄位須非空字串且批內唯一(精確 ===); 'id'(預設)不另檢查;
720
+ //只有「對應鍵」才是登入查找(getTokenUser)之鍵, 其唯一性才是安全前提——預設部署之 email 可空/可重複, 不得因新規則使其外部同步全數失敗. log 不記對應值(個資)
721
+ if (keyTable === 'users') {
722
+ let mk = checkMapKeyBatch(rows, mappingBy)
723
+ if (mk !== null) {
724
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: mk.reason, keyTable, from, row: mk.k, field: mappingBy })
725
+ return Promise.reject(`invalidRows`)
726
+ }
727
+ }
728
+
661
729
  //寫入前驗證(規則帳本 R-ORM-01, ADR-026): 於 delAll 之前擋下可預判之寫入失敗, 該 from 之既有列不變(ADR-008 防誤清源).
662
730
  //why: 先 delAll 再 insert, 而 w-serv-orm 1.1.8 起對「會被 clearXSS 改寫之主鍵」整批拒絕、LMDB 對 > 1978 B 之主鍵整批拒絕,
663
731
  //修正前遇之即該 from 之既有列全數清空(2026-10-14 V4/V7 實測). 主鍵問題 → invalidRows; 任一字串欄位含標記 → htmlMarkupNotAllowed
@@ -702,21 +770,140 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
702
770
  }
703
771
  }
704
772
 
705
- //delAll from, insert; ORM/I/O 失敗之原文只入 srLog, 外部應用收 saveWriteFail(規則帳本 R-ORM-02)
706
- let r = null
707
- try {
708
- await woItems[keyTable].delAll({ from })
709
- r = await procOrm(userId, keyTable, 'insert', rows) //須使用procOrm才有辦法自動給予相關欄位
773
+ //跨 from 同名/同對應鍵(F-02/F-14, 規則帳本 R-NAME-UNIQ-01/R-MAPKEY-01): grups/pemis 之 name、users 於 mappingBy 為 email/name 時之該欄位(僅 active 列),
774
+ //已為其他 from 之既有列使用 → 整批拒絕且不進 delAll; 同 from 之既有列不算衝突(隨後由 delAll 重寫). 先 select 後比對, select 失敗之處置同上段(toOutKey, 未寫入任何資料)
775
+ let keyOther = (keyName === 'name') ? 'name' : ((keyTable === 'users') ? mapKeyOf(mappingBy) : null)
776
+ if (keyOther !== null) {
777
+ let rsAll = null
778
+ try {
779
+ rsAll = await woItems[keyTable].select()
780
+ }
781
+ catch (err) {
782
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
783
+ let key = toOutKey(err, 'anUnexpectedErrorOccurred') //尚未寫入任何資料, 不回 saveWriteFail
784
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
785
+ return Promise.reject(key)
786
+ }
787
+ let kOther = findKeyOtherFrom(rows, rsAll, from, keyOther, { onlyActive: keyTable === 'users' })
788
+ if (kOther >= 0) {
789
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: (keyTable === 'users') ? 'mapKeyOtherFrom' : 'nameOtherFrom', keyTable, from, row: kOther, field: keyOther })
790
+ return Promise.reject('invalidRows')
791
+ }
710
792
  }
711
- catch (err) {
712
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
713
- let key = toOutKey(err)
714
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from })
715
- return Promise.reject(key)
793
+
794
+ //外部同步 payload 缺指派欄鍵之列保留庫內值(F-23; 主控定案: 指派欄屬後台, 同步不得清掉後台指派; 含該鍵則以外部為準): 修正前 ltdtmapping 缺鍵補 ''、delAll+insert 即清空後台已指派者.
795
+ //三個指派欄(users.cgrups/grups.cpemis/pemis.crules; run08 起 cpemis/crules 比照 cgrups)皆是; 只在確有缺鍵之列時才讀庫內(同一 from).
796
+ //users: 對應先依 id、無 id 者依 mappingBy(email/name)恰命中 1 筆, 對不到者視為新使用者(維持 ''), 對應鍵矛盾者不保留(keepAssignOnMissing);
797
+ //grups/pemis: 無登入對應鍵, 實體身分依 id(無 id 者依名稱恰 1 筆; 改名而 cascadeRename 為假者不保留; keepAssignByEntity). select 失敗之處置同上段(toOutKey, 尚未寫入任何資料)
798
+ if (hasAssign !== null && hasAssign.some((b) => b === false)) {
799
+ let rsSame = null
800
+ try {
801
+ rsSame = await woItems[keyTable].select({ from }) //此處 keyTable 為 users/grups/pemis(hasAssign 非 null); users 非登入查找, 不經 pickUniqueUser(SITE-KC-004 只管登入反查)
802
+ }
803
+ catch (err) {
804
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
805
+ let key = toOutKey(err, 'anUnexpectedErrorOccurred')
806
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
807
+ return Promise.reject(key)
808
+ }
809
+ //isKeepable: 保留值於 delAll 之前須再通過一次寫入前驗證(上方 validateRowsBeforeWrite 看到的是補出之空字串, 外審 sol R4); 不過者不保留(視為空, 收嚴), 只記筆數
810
+ let isKeepable = (val) => validateRowsBeforeWrite([{ id: 'keep', [keyAssign]: val }]) === null
811
+ let kr = (keyTable === 'users') ? keepAssignOnMissing({ rows, hasKey: hasAssign, rowsOldSameFrom: rsSame, mappingBy, isKeepable }) : keepAssignByEntity({ rows, hasKey: hasAssign, rowsOldSameFrom: rsSame, key: keyAssign, cascadeRename, isKeepable })
812
+ rows = kr.rows
813
+ if (kr.nKeep > 0) {
814
+ srLog.info({ event: 'syncAndReplaceTabs-keepAssign', keyTable, from, field: keyAssign, n: kr.nKeep })
815
+ }
816
+ if (kr.nSkip > 0) {
817
+ srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nSkip, reason: 'notKeepable' })
818
+ }
819
+ if (kr.nConflict > 0) {
820
+ srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nConflict, reason: (keyTable === 'users') ? 'mapKeyConflict' : 'identityConflict' }) //users: 對應鍵與 id 指向不同使用者; grups/pemis: 同名多筆無從對應或改名而引用不跟著改; 指派不移轉而清空(收嚴須附提示; 不含個資值)
821
+ }
822
+ }
823
+
824
+ //引用連帶(適用表 m/n, R-REF-CASCADE-01): 同步 grups/pemis/targets 時, 同一 from 內依穩定 id 判定——同 id 異名=改名級聯、舊 id 不在本批=刪除而連帶清理引用(寫入後仍存續之同名者不清理);
825
+ //未帶 id 之列(ORM 每次補隨機主鍵)改依名稱對應(見 buildSyncRowsNew). 預檢(新名撞懸空名、歷史同名組改名、容器內相撞、改寫後欄位驗證)於 delAll 之前, 被拒整批零變更;
826
+ //外部應用只收固定語系鍵 invalidRows/htmlMarkupNotAllowed(R-ORM-02), 動態原因入 srLog. select 失敗之處置同上段(toOutKey, 未寫入任何資料).
827
+ //是否須改寫引用端只依本表新舊列判定(diffRefTargets), 持有本表鎖之下即可決定是否再取引用端表之鎖(R-WRITE-LOCK-02); 取齊鎖之後才讀取引用端、規劃與預檢
828
+ let rowsPlan = null
829
+ let kind = null
830
+ if (Object.prototype.hasOwnProperty.call(REF_OF, keyTable)) {
831
+ try {
832
+ let rowsOld = await woItems[keyTable].select()
833
+ rowsPlan = { rowsOld, rowsNew: buildSyncRowsNew(keyTable, rowsOld, rows, from) }
834
+ }
835
+ catch (err) {
836
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
837
+ let key = toOutKey(err, 'anUnexpectedErrorOccurred')
838
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
839
+ return Promise.reject(key)
840
+ }
841
+ kind = diffRefTargets({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, opt: { cascadeRename } })
842
+
843
+ //外部 id 不穩定之偵測(主控定案: 不加開關; 同一 from 同一批內出現「同名異 id」=疑似 id 重編, id 消失視為刪除會每次清理引用而可能抹掉後台指派): 記 warn log(不記個資值, 只記筆數)
844
+ //並登錄供 getWebInfor.syncWarns 於頁面提示; 之後同一(表, from)之同步已無此情形即清除提示. 只適用有 name 之 grups/pemis(users 之 name 可重複, targets 之 id 即名稱)
845
+ if (keyTable === 'grups' || keyTable === 'pemis') {
846
+ let nRenum = countIdRenumber(rowsPlan.rowsOld.filter((o) => o !== null && typeof o === 'object' && o.from === from), rows)
847
+ setSyncWarn(keyTable, from, nRenum)
848
+ if (nRenum > 0) {
849
+ srLog.warn({ event: 'syncAndReplaceTabs-idRenumber', keyTable, from, n: nRenum })
850
+ }
851
+ }
852
+ }
853
+
854
+ //doWrite: delAll from, insert; ORM/I/O 失敗之原文只入 srLog, 外部應用收 saveWriteFail(規則帳本 R-ORM-02); 引用端先寫、失敗補償還原(cascadeWrite.mjs)
855
+ let doWrite = async (plan, holdersOld) => {
856
+ let r = null
857
+ try {
858
+ r = await writeWithCascade({
859
+ plan,
860
+ holdersOld,
861
+ woItems,
862
+ procOrm,
863
+ userId,
864
+ srLog,
865
+ writeMain: async () => {
866
+ await woItems[keyTable].delAll({ from })
867
+ return await procOrm(userId, keyTable, 'insert', rows) //須使用procOrm才有辦法自動給予相關欄位
868
+ },
869
+ })
870
+ }
871
+ catch (err) {
872
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
873
+ let key = toOutKey(err)
874
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from })
875
+ return Promise.reject(key)
876
+ }
877
+ // console.log('r', r)
878
+ return r
716
879
  }
717
- // console.log('r', r)
718
880
 
719
- return r
881
+ //有改名或刪除: 再取引用端表之鎖, 讀取引用端、規劃、預檢後寫入
882
+ if (kind !== null && (kind.renames.length > 0 || kind.delNames.length > 0)) {
883
+ let holder = REF_OF[keyTable].holder
884
+ return await withTabLocks(kmx, [holder], async () => {
885
+ let holdersOld = null
886
+ try {
887
+ holdersOld = await woItems[holder].select()
888
+ }
889
+ catch (err) {
890
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
891
+ let key = toOutKey(err, 'anUnexpectedErrorOccurred')
892
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
893
+ return Promise.reject(key)
894
+ }
895
+ let plan = planRefChanges({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, holdersOld, opt: { cascadeRename } })
896
+ let errPlan = plan.err !== null ? plan.err : checkCascadePlan(plan)
897
+ if (errPlan !== null) {
898
+ let key = (errPlan === 'htmlMarkupNotAllowed') ? 'htmlMarkupNotAllowed' : 'invalidRows'
899
+ srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, reason: errPlan, keyTable, from })
900
+ return Promise.reject(key)
901
+ }
902
+ return await doWrite(plan, holdersOld)
903
+ }, [keyTable])
904
+ }
905
+
906
+ return await doWrite(null, [])
720
907
 
721
908
  })
722
909
  }
@@ -725,9 +912,9 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
725
912
  //getGenUserByKV
726
913
  let getGenUserByKV = async(keyUser, valueUser) => {
727
914
 
728
- //userFind
729
- let userFind = await woItems.users.select({ [keyUser]: valueUser, isActive: 'y' })
730
- userFind = get(userFind, 0, null)
915
+ //userFind, 與 getTokenUser 同一判準(pickUniqueUser): 非恰 1 筆不取首筆(目前僅以主鍵 id 呼叫, 恆 ≤ 1 筆)
916
+ let userRs = await woItems.users.select({ [keyUser]: valueUser, isActive: 'y' })
917
+ let { user: userFind } = pickUniqueUser(userRs)
731
918
 
732
919
  //check
733
920
  if (!iseobj(userFind)) {
@@ -772,8 +959,17 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
772
959
  let targets = await woItems.targets.select()
773
960
  // console.log('targets', targets)
774
961
 
775
- //getUserRules
776
- let kur = getUserRules(userFind, grups, pemis, targets)
962
+ //getUserRules, 評估時之任何例外一律轉為固定語系鍵 cannotGetUserRules: 不得把引擎原文(如 TypeError 的 message)經 getPerm/getPermUserInfor 回給外部應用(R-KEY-PROP-01, 錯誤一律回 key)
963
+ let kur = null
964
+ try {
965
+ kur = getUserRules(userFind, grups, pemis, targets)
966
+ }
967
+ catch (err) {
968
+ console.log('userId', userId)
969
+ console.log('getUserRules', err)
970
+ srLog.error({ event: 'getGenUserAndRulesByUserId-error', msg: 'cannotGetUserRules', userId })
971
+ return Promise.reject(`cannotGetUserRules`)
972
+ }
777
973
  // console.log('getUserRules', kur)
778
974
 
779
975
  //check
@@ -1222,12 +1418,16 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1222
1418
 
1223
1419
  getWebInfor,
1224
1420
 
1225
- updateTargets: pc.updateTargets,
1226
- updatePemis: pc.updatePemis,
1227
- updateGrups: pc.updateGrups,
1421
+ //updateXxx 第 3 參 opt 為用戶端給之 { baseline }(草稿載入基準, F-26, R-BASELINE-01), 其餘鍵一律不採納(pickKeysOnly 等為伺服端設定); 未給(舊客戶端)見 procCore 之處置
1422
+ updateTargets: (userId, rows, opt) => pc.updateTargets(userId, rows, opt),
1423
+ updatePemis: (userId, rows, opt) => pc.updatePemis(userId, rows, opt),
1424
+ updateGrups: (userId, rows, opt) => pc.updateGrups(userId, rows, opt),
1228
1425
  //updateUsers, modeEditUsers='for:grups' 時 API 與 UI 一致只採納既有使用者之 cgrups 變更, 身分欄/新增/刪除/順序以資料庫為準
1229
- updateUsers: (userId, rows) => {
1230
- let opt = (modeEditUsers === 'for:grups') ? { pickKeysOnly: ['cgrups'] } : {}
1426
+ updateUsers: (userId, rows, optClient) => {
1427
+ let opt = (optClient !== null && typeof optClient === 'object' && optClient.baseline !== undefined && optClient.baseline !== null) ? { baseline: optClient.baseline } : {}
1428
+ if (modeEditUsers === 'for:grups') {
1429
+ opt.pickKeysOnly = ['cgrups']
1430
+ }
1231
1431
  return pc.updateUsers(userId, rows, opt)
1232
1432
  },
1233
1433
 
@@ -0,0 +1,153 @@
1
+ import truncate from 'lodash-es/truncate.js'
2
+ import ds from '../src/schema/index.mjs'
3
+ import { fillIdlessIds } from '../src/js/refCascade.mjs'
4
+ import validateRowsBeforeWrite from './validateRows.mjs'
5
+
6
+
7
+ //引用連帶之寫入(規則帳本 R-REF-CASCADE-01, ADR-036): 把 planRefChanges(src/js/refCascade.mjs)算出之引用端改寫與被引用表自身之寫入串成「引用端先、主體後」, 並於失敗時補償還原引用端.
8
+ //單一擁有者: 後台整表寫入(procCore.updateTabItems)與外部同步(WWebPerm.syncAndReplaceTabs)皆經本檔, 不各自寫.
9
+ //
10
+ //為何引用端先、主體後, 以及失敗回復(外審 A.2「重存即自癒」對互換/鏈式/批內有新增不成立, 本檔改為補償, 見 ADR-036):
11
+ // ① 全部預檢(含引用端改寫列之欄位驗證)於任何寫入之前完成, 失敗零變更;
12
+ // ② 先寫引用端(僅改寫之欄位, 經 procOrm 以操作者記為更新者), 再寫主體; 任一步以例外失敗, 即以「寫入前之原值」還原引用端(補償, 精確還原該欄位與更新者/更新時間),
13
+ // 使資料回到寫入前之狀態再回報原錯誤, 重新操作不依賴「冪等重存」; 主體部分寫入之舊限制不變(ADR-013: 整表寫入為 del → insert → save 非交易, 其部分寫入之處置維持「重新整理後確認」, 本檔不補償主體);
14
+ // ③ 補償本身失敗(雙重故障)時回 saveCascadePartial 並記 srLog(event refCascade-restore-error, 帶引用端表名與列數, 不記個資值), 由稽核工具(server/tools/authRefsAudit.mjs)列出懸空引用後人工處置;
15
+ // ④ 行程於引用端寫入後、主體寫入前崩潰者不在補償範圍(記憶體內無日誌), 同③以稽核工具處置.
16
+ //鎖: 呼叫端須已以 withTabLocks 取得被引用表與引用端表之鎖(server/keyCheck.mjs); 本檔不取鎖.
17
+
18
+
19
+ /**
20
+ * 預檢引用端改寫列(於任何寫入之前): 改寫後欄位值須可寫入(無 HTML 標記、純量、長度等同後台整表寫入之 validateRowsBeforeWrite)
21
+ *
22
+ * @param {Object} plan 輸入 planRefChanges 之結果
23
+ * @returns {String|null} 回傳 null 為通過; 否則為語系鍵(htmlMarkupNotAllowed 或 saveRowFieldInvalid)
24
+ */
25
+ function checkCascadePlan(plan) {
26
+ if (!plan || plan.changes.length === 0) {
27
+ return null
28
+ }
29
+ let ve = validateRowsBeforeWrite(plan.changes.map((c) => ({ id: c.id, [c.field]: c.after })))
30
+ if (ve === null) {
31
+ return null
32
+ }
33
+ return (ve.reason === 'htmlMarkup') ? 'htmlMarkupNotAllowed' : 'saveRowFieldInvalid'
34
+ }
35
+
36
+
37
+ /**
38
+ * 執行引用連帶之寫入: 先寫引用端(改寫之欄位), 再執行 writeMain(被引用表自身之寫入), 失敗時補償還原引用端後重新拋出原錯誤
39
+ *
40
+ * @param {Object} p 輸入參數物件
41
+ * @param {Object} p.plan 輸入 planRefChanges 之結果(無 changes 時直接執行 writeMain)
42
+ * @param {Array} p.holdersOld 輸入引用端表全部列(寫入前之 select 結果, 供還原更新者與更新時間)
43
+ * @param {Object} p.woItems 輸入 ORM 表物件集合(補償直接用 woItems 寫回原值, 不經 procOrm 之稽核欄位覆寫)
44
+ * @param {Function} p.procOrm 輸入 procOrm(userId, 表名, 方法, 資料)
45
+ * @param {String} p.userId 輸入操作者 id
46
+ * @param {Object} p.srLog 輸入 srLog
47
+ * @param {Function} p.writeMain 輸入被引用表自身之寫入函數(async)
48
+ * @returns {Promise} 回傳 writeMain 之結果
49
+ */
50
+ async function writeWithCascade({ plan, holdersOld, woItems, procOrm, userId, srLog, writeMain }) {
51
+ if (!plan || plan.changes.length === 0) {
52
+ return await writeMain()
53
+ }
54
+
55
+ //防禦: 引用端改寫列之欄位預檢(呼叫端應已於寫入前以 checkCascadePlan 預檢; 此處再驗一次, 使本函式無論被誰呼叫皆不會在驗證未過時寫入任何一列)
56
+ let errPlan = checkCascadePlan(plan)
57
+ if (errPlan !== null) {
58
+ return Promise.reject(errPlan)
59
+ }
60
+
61
+ //pre: 還原用之寫入前整列(補償以整列覆寫寫回, 使引用端列逐位元回到寫入前; 歷史列若原無 userIdUpdate/timeUpdate, 寫入時新增之該二鍵亦隨覆寫消失——合併寫入無法刪鍵; merge:false 為 w-orm-lmdb 之選項, 其他後端不提供而退回合併寫入, 此時原有該二鍵者仍精確還原)
62
+ let kpHolder = new Map()
63
+ holdersOld.forEach((h) => {
64
+ if (h !== null && typeof h === 'object') {
65
+ kpHolder.set(h.id, h)
66
+ }
67
+ })
68
+ let pre = plan.changes.map((c) => {
69
+ let h = kpHolder.get(c.id)
70
+ return h ? { ...h } : { id: c.id, [c.field]: c.before }
71
+ })
72
+ let post = plan.changes.map((c) => ({ id: c.id, [c.field]: c.after }))
73
+
74
+ //toRaw: 錯誤原文只入 srLog(raw、rawRestore; 規則帳本 R-ORM-02), 對外仍為固定語系鍵; 原文中之主鍵(ORM 逐筆失敗訊息之 id[…])可能為引用端(使用者)之 id, 一律遮罩後才入 log(不記引用端 id)
75
+ //先以「已知引用端 id 之完整字面」遮罩(id 本身可含 ']' 而使通則正則提早結束, 殘留後半段個資; 外審 sol 增量 D1), 再以通則遮罩其餘 id[…]
76
+ //ORM 之逐筆失敗訊息(w-serv-orm getMapOrm.getMsgFail)把超過 100 字元之主鍵截斷為 99 字元加「…」再包成 id[...](外審 sol R7), 截斷後之字面不等於完整 id, 故同時納入截斷形式之字面
77
+ let holderIds = plan.changes.map((c) => c.id).filter((v) => typeof v === 'string' && v !== '').flatMap((id) => {
78
+ let t = truncate(id, { length: 100, omission: '…' })
79
+ return t === id ? [id] : [id, t]
80
+ }).sort((a, b) => b.length - a.length) //較長者先遮罩(id 互為前綴時, 如 'a' 與 'a]b', 較短者先遮罩會殘留後半段)
81
+ let maskId = (t) => {
82
+ let s = t
83
+ holderIds.forEach((id) => {
84
+ s = s.split(`id[${id}]`).join('id[*]')
85
+ })
86
+ return s.replace(/id\[[^\]]*\]/g, 'id[*]')
87
+ }
88
+ let toRaw = (e) => maskId((typeof e === 'string') ? e : ((e && e.message) || String(e)))
89
+
90
+ let rm = null
91
+ let stage = 'holder'
92
+ try {
93
+ await procOrm(userId, plan.holder, 'save', post) //須使用procOrm才有辦法自動給予更新者與更新時間
94
+ stage = 'main'
95
+ rm = await writeMain()
96
+ }
97
+ catch (err) {
98
+
99
+ //補償: 以寫入前整列還原引用端(逐列結果為 ok:0 者亦算失敗)
100
+ let okRestore = true
101
+ let rawRestore = ''
102
+ try {
103
+ let rs = await woItems[plan.holder].save(pre, { merge: false })
104
+ if (Array.isArray(rs) && rs.some((v) => v && v.ok === 0)) {
105
+ okRestore = false
106
+ rawRestore = 'save result ok:0'
107
+ }
108
+ }
109
+ catch (errRestore) {
110
+ okRestore = false
111
+ rawRestore = toRaw(errRestore)
112
+ }
113
+ if (!okRestore) {
114
+ srLog.error({ event: 'refCascade-restore-error', msg: 'saveCascadePartial', raw: toRaw(err), rawRestore, userId, keyTable: plan.keyTable, holder: plan.holder, nHolders: plan.changes.length })
115
+ return Promise.reject('saveCascadePartial')
116
+ }
117
+ srLog.warn({ event: 'refCascade-restored', raw: toRaw(err), userId, keyTable: plan.keyTable, holder: plan.holder, nHolders: plan.changes.length })
118
+
119
+ //重新拋出原錯誤: 引用端寫入階段之錯誤原文可能含引用端 id, 外層(procCore/syncAndReplaceTabs)會再把 raw 記入 srLog, 故於此遮罩後才拋出; 主體寫入階段之錯誤原樣拋出(其 id 為群組/權限/標的之主鍵, 非個資)
120
+ if (stage === 'holder') {
121
+ return Promise.reject((typeof err === 'string') ? maskId(err) : ((err instanceof Error) ? new Error(maskId(err.message)) : err))
122
+ }
123
+ return Promise.reject(err)
124
+
125
+ }
126
+
127
+ srLog.info({ event: 'refCascade-done', userId, keyTable: plan.keyTable, holder: plan.holder, nRename: plan.renames.length, nDelName: plan.delNames.length, nHolders: plan.nHolders, nRenameHolders: plan.nRenameHolders, nDelHolders: plan.nDelHolders, nExternal: plan.nExternal })
128
+
129
+ return rm
130
+
131
+ }
132
+
133
+
134
+ /**
135
+ * 外部同步(delAll({ from }) + insert)之「寫入後整表」, 供 planRefChanges 判定同一 from 內之改名與刪除(Q6: 依同一來源內之穩定 id 判定)
136
+ *
137
+ * 他 from 之既有列原樣; 本批各列帶 id 者以其 id(有限數字取字串形式)為身分(同 id 異名=改名、舊 id 不在本批=刪除);
138
+ * 本批未帶 id 之列(grups/pemis, 寫入時由 ORM 補隨機主鍵, 每次同步皆不同)無穩定 id 可依, 改以「名稱」對應同一 from 之既有列(同名即同一實體, 不算刪除再新增; 名稱不同即視為舊列刪除、新列新增), 僅供規劃, 不改寫入之列.
139
+ *
140
+ * @param {String} keyTable 輸入同步之表名
141
+ * @param {Array} rowsOld 輸入該表全部既有列(select 結果)
142
+ * @param {Array} rows 輸入本批列(已 ltdtmapping 並帶 from)
143
+ * @param {String} from 輸入本批之 from
144
+ * @returns {Array} 回傳寫入後之整表(僅供規劃, 不寫入)
145
+ */
146
+ function buildSyncRowsNew(keyTable, rowsOld, rows, from) {
147
+ let others = rowsOld.filter((r) => r !== null && typeof r === 'object' && r.from !== from)
148
+ let sameFrom = rowsOld.filter((r) => r !== null && typeof r === 'object' && r.from === from)
149
+ return [...others, ...fillIdlessIds(keyTable, sameFrom, rows, others)] //未帶 id 之列依名稱對應同一 from 之既有列(見 fillIdlessIds); 規劃 id 須避開他 from 列之主鍵
150
+ }
151
+
152
+
153
+ export { checkCascadePlan, writeWithCascade, buildSyncRowsNew }