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
@@ -0,0 +1,138 @@
1
+ import eachOwn from './eachOwn.mjs'
2
+ import { countAndRefs, fmtWarn } from './getAuthWarn.mjs'
3
+ import { planRefChanges } from './refCascade.mjs'
4
+
5
+
6
+ //群組頁/權限頁按「刪除」前, 計算刪除對引用端的影響, 並產生確認框文字(規則帳本 R-PRINCIPLE-00 c、d、原則 6、適用表 n; ADR-036):
7
+ // n: 刪除後, 引用被刪名稱之引用端列(任何 mode、含停用項目)將於同一次寫入內被連帶清理(自其授權字串移除該引用), 其數量以 planRefChanges 之 nDelHolders 計(與伺服端同一純函數, R-REF-CASCADE-01);
8
+ // c: 其中以 AND 引用且刪除後該名稱完全消失者 → 引用被移除後其 AND 限制解除, 授權可能變多(run05 前為「AND 項變懸空、授權被收回」, 與連帶清理互斥, 已改);
9
+ // d: 引用的名稱有多筆同名(歷史同名)而只刪其一 → 取各候選之交集, 候選變少則交集變寬, 其授權可能變多(任何 mode; 該名稱仍有實體, 引用不清理, 不計入 n).
10
+ //兩頁共用: 群組頁引用端為 users(欄位 cgrups)、權限頁引用端為 grups(欄位 cpemis). 純函數, 不依賴 Vue/DOM.
11
+
12
+
13
+ //KIND_OF: 被刪者種類 → 被刪表與引用欄位('Grup' 群組頁、'Pemi' 權限頁)
14
+ let KIND_OF = {
15
+ Grup: { keyTable: 'grups', field: 'cgrups' },
16
+ Pemi: { keyTable: 'pemis', field: 'cpemis' },
17
+ }
18
+
19
+
20
+ /**
21
+ * 計算刪除所選列對引用者授權的影響(其中 AND 與歷史同名兩項)
22
+ *
23
+ * 被刪名稱只取「基準(store 之已儲存列)中存在」之列的名稱: 尚未儲存的新增列(id 不在基準)刪除不影響他人;
24
+ * 以基準名稱為準(引用者所寫的是已儲存的名稱, 未儲存的改名不影響其引用). 「刪除後」以刪除後的剩餘列(含未儲存的新增與改名)判斷,
25
+ * 「刪除前」之同名筆數以基準列計.
26
+ *
27
+ * @param {Array} delIds 輸入被刪列的 id 陣列
28
+ * @param {Array} rowsBase 輸入基準列(store 之 grups 或 pemis)
29
+ * @param {Array} holders 輸入引用者列(store 之 users 或 grups)
30
+ * @param {String} field 輸入引用欄位名('cgrups' 或 'cpemis')
31
+ * @param {Array} rowsRemain 輸入刪除後剩餘之列(目前清單剔除被刪列)
32
+ * @returns {Object} 回傳 { n, nWiden }: n 為以 AND 引用且名稱完全消失之引用者數(其 AND 限制將隨引用被清理而解除), nWiden 為授權可能變多之引用者數(歷史同名只刪其一)
33
+ * @example
34
+ *
35
+ * let users = [{ cgrups: '{"G1":{"mode":"AND","isActive":"y"}}' }]
36
+ * console.log(getDelRefCount(['g1'], [{ id: 'g1', name: 'G1' }], users, 'cgrups', []))
37
+ * // => { n: 1, nWiden: 0 }
38
+ *
39
+ */
40
+ function getDelRefCount(delIds, rowsBase, holders, field, rowsRemain) {
41
+ let setIds = new Set(Array.isArray(delIds) ? delIds : [])
42
+ let delNames = []
43
+ eachOwn(rowsBase, (r) => {
44
+ if (r !== null && typeof r === 'object' && setIds.has(r.id)) {
45
+ delNames.push(r.name === undefined ? '' : r.name)
46
+ }
47
+ })
48
+ if (delNames.length === 0) {
49
+ return { n: 0, nWiden: 0 }
50
+ }
51
+ let res = countAndRefs(holders, field, delNames, rowsRemain, rowsBase)
52
+ return { n: res.n, nWiden: res.nWiden }
53
+ }
54
+
55
+
56
+ /**
57
+ * 計算刪除所選列對引用端之完整影響: 將被連帶清理(移除引用)之引用端列數、其中以 AND 引用者、歷史同名致授權可能變多者
58
+ *
59
+ * nRefs 以 planRefChanges 計(只算本次刪除: rowsOld 為基準列、rowsNew 為基準列剔除被刪 id 之列, 不含其他未儲存之編輯; holdersOld 為引用端列),
60
+ * 即伺服端寫入時實際會被改寫之引用端列數(任何 mode、含停用項目); nAnd/nWiden 沿用 getDelRefCount.
61
+ * nAnd 以 nRefs 為上限(AND 引用者本即屬被清理者之子集; 若「目前清單之未儲存改名」使兩者判準不一致, 不使文案出現「其中 M 位」大於總數).
62
+ *
63
+ * @param {Array} delIds 輸入被刪列的 id 陣列
64
+ * @param {Array} rowsBase 輸入基準列(store 之 grups 或 pemis)
65
+ * @param {Array} holders 輸入引用端列(store 之 users 或 grups)
66
+ * @param {String} kind 輸入被刪者種類: 'Grup'(群組頁)或 'Pemi'(權限頁); 其他值回傳全 0
67
+ * @param {Array} rowsRemain 輸入刪除後剩餘之列(目前清單剔除被刪列)
68
+ * @returns {Object} 回傳 { nRefs, nAnd, nWiden }
69
+ * @example
70
+ *
71
+ * let users = [{ id: 'u1', cgrups: '{"G1":{"mode":"AND","isActive":"y"}}' }, { id: 'u2', cgrups: '{"G1":{"mode":"OR","isActive":"n"}}' }]
72
+ * console.log(getDelImpact(['g1'], [{ id: 'g1', name: 'G1' }], users, 'Grup', []))
73
+ * // => { nRefs: 2, nAnd: 1, nWiden: 0 }
74
+ *
75
+ */
76
+ function getDelImpact(delIds, rowsBase, holders, kind, rowsRemain) {
77
+ let res = { nRefs: 0, nAnd: 0, nWiden: 0 }
78
+ if (!Object.prototype.hasOwnProperty.call(KIND_OF, kind)) {
79
+ return res
80
+ }
81
+ let ref = KIND_OF[kind]
82
+ let base = Array.isArray(rowsBase) ? rowsBase : []
83
+ let setIds = new Set(Array.isArray(delIds) ? delIds : [])
84
+ let rowsNew = base.filter((r) => !(r !== null && typeof r === 'object' && setIds.has(r.id)))
85
+ let plan = planRefChanges({
86
+ keyTable: ref.keyTable,
87
+ rowsOld: base,
88
+ rowsNew,
89
+ holdersOld: Array.isArray(holders) ? holders : [],
90
+ })
91
+ //名稱是否「仍有存續列」依伺服端之身分判準(refCascade.diffRefTargets): 只有「同 id 同名」之列算存續, 刪除後以同名新增之新 id 列或由他列改名而來者不繼承引用, 故不算(否則 nRefs 計其被清理而 nAnd 以為名稱仍在, 漏報 AND 限制解除)
92
+ let nameOf = (r) => (r.name === undefined ? '' : r.name)
93
+ let baseById = new Map()
94
+ base.forEach((r) => {
95
+ if (r !== null && typeof r === 'object' && typeof r.id === 'string') {
96
+ baseById.set(r.id, r)
97
+ }
98
+ })
99
+ let remainRetained = (Array.isArray(rowsRemain) ? rowsRemain : []).filter((r) => {
100
+ let b = (r !== null && typeof r === 'object') ? baseById.get(r.id) : undefined
101
+ return b !== undefined && nameOf(b) === nameOf(r)
102
+ })
103
+ let c = getDelRefCount(delIds, rowsBase, holders, ref.field, remainRetained)
104
+ res.nRefs = plan.nDelHolders
105
+ res.nAnd = Math.min(c.n, res.nRefs)
106
+ res.nWiden = c.nWiden
107
+ return res
108
+ }
109
+
110
+
111
+ /**
112
+ * 刪除確認框文字: 有引用端將被清理(nRefs)或授權可能變多(nWiden)任一大於 0 才有文字(單一框、單一問句); 皆為 0 回空字串(不彈框)
113
+ *
114
+ * 文案依 nRefs/nAnd/nWiden 組合選鍵(語系鍵尾碼 kind): 僅 nRefs → confirmDelRefs; nRefs+nAnd → confirmDelRefsAnd; nRefs+nWiden → confirmDelRefsDup;
115
+ * 三者皆有 → confirmDelRefsAndDup; 僅 nWiden → confirmDelDupRefs({n} 為 nWiden). nAnd 為 0 或 nRefs 為 0 時不提 AND(nAnd 以 nRefs 為上限).
116
+ *
117
+ * @param {Object} impact 輸入 getDelImpact 之結果 { nRefs, nAnd, nWiden }
118
+ * @param {Function} $t 輸入語系函數(key → 文字)
119
+ * @param {String} kind 輸入被刪者種類: 'Grup'(群組頁) 或 'Pemi'(權限頁), 組成語系鍵 confirmDelRefs/confirmDelRefsAnd/confirmDelRefsDup/confirmDelRefsAndDup/confirmDelDupRefs 之尾碼
120
+ * @returns {String} 回傳確認框文字, 無影響為空字串
121
+ */
122
+ function getDelConfirmText(impact, $t, kind) {
123
+ let n = impact && impact.nRefs > 0 ? impact.nRefs : 0
124
+ let m = n > 0 && impact && impact.nAnd > 0 ? Math.min(impact.nAnd, n) : 0
125
+ let w = impact && impact.nWiden > 0 ? impact.nWiden : 0
126
+ if (n > 0) {
127
+ let key = `confirmDelRefs${m > 0 ? 'And' : ''}${w > 0 ? 'Dup' : ''}${kind}`
128
+ return fmtWarn($t(key), { n, m, w })
129
+ }
130
+ if (w > 0) {
131
+ return fmtWarn($t(`confirmDelDupRefs${kind}`), { n: w })
132
+ }
133
+ return ''
134
+ }
135
+
136
+
137
+ export { getDelRefCount, getDelImpact, getDelConfirmText }
138
+ export default getDelRefCount
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 取得合併模式之有效值: 'OR' 為 'OR', 其餘(缺、非字串、'or'、'XOR' 等無法判讀者)一律視為 'AND'
3
+ *
4
+ * 規則帳本 R-AUTH-MERGE-00 第 5 條、R-PRINCIPLE-00 e: 無法判讀時取最嚴之解讀. 略過該項會使 AND 限制消失而放寬, 故改視為 AND(mergeRules 中為「限制項」:只當交集項、不單獨當基底,該層無合法 mode 之啟用項時全 n,否則唯一項成基底而比修正前多給).
5
+ * 伺服端 mergeRules(src/plugins/mShare.mjs)、編輯器解碼(cvKpToObj)、無損表示判定(getKpLossy)與頁面提示(getAuthWarn)共用, 單一擁有者.
6
+ *
7
+ * @param {*} m 輸入 mode
8
+ * @returns {String} 回傳 'OR' 或 'AND'
9
+ * @example
10
+ *
11
+ * console.log(getEffMode('OR'), getEffMode('AND'), getEffMode('or'), getEffMode(undefined))
12
+ * // => OR AND AND AND
13
+ *
14
+ */
15
+ function getEffMode(m) {
16
+ return m === 'OR' ? 'OR' : 'AND'
17
+ }
18
+
19
+
20
+ export default getEffMode
@@ -1,21 +1,28 @@
1
- import isestr from 'wsemi/src/isestr.mjs'
2
1
  import eachOwn from './eachOwn.mjs'
3
2
  import cvKpToArr from './cvKpToArr.mjs'
4
- import isAuthMode from './isAuthMode.mjs'
3
+ import getEffMode from './getEffMode.mjs'
5
4
 
6
5
 
7
6
  /**
8
7
  * 判定帶 mode 之授權資料(JSON5.parse(cgrups/cpemis) 之結果)是否含「編輯器無法以物件形式無損表示」之格, 回傳受影響之名稱
9
8
  *
10
- * 編輯器存檔一律寫物件形式 { 名稱: { mode, isActive } }(每個名稱只有一個 mode、鍵序為 JS 屬性序); 外部應用可直接寫入陣列形式, 其下列兩格物件形式無法無損表示,
9
+ * 編輯器存檔一律寫物件形式 { 名稱: { mode, isActive } }(每個名稱只有一個 mode); 外部應用可直接寫入陣列形式, 其下列兩格物件形式無法無損表示,
11
10
  * 硬寫即靜默改變授權(規則帳本 R-AUTH-LOSSY-01; 複審 R4), 編輯器須拒存並顯示固定訊息(authLossyBlocked), 庫內字串不動:
12
- * 1. 同名有效啟用元素同時為 OR 與 AND: 物件形式只能留一個 mode. 保留 AND 支者, 另有其他 AND 名稱時 AND 合併之起始值由 OR 聯集改為第一個 AND 項而增權(外審 sol 第三輪#2);
13
- * 保留 OR 支或停用者亦會減權或增權, 一律視為有損(此格若整組只有該名稱則碰巧相等, 仍從嚴判定).
14
- * 2. 有效 AND 名稱 ≥ 2 且其相對順序於伺服端序(陣列元素首次有效出現之序)與物件鍵序(整數式鍵先、遞增)不同:
15
- * mergeRules 在沒有有效 OR 項時以第一個 AND 項為 AND 合併之起始, 結果因之而異; 整數式名稱(如群組名 '1')在物件形式中恆排在前面, 陣列形式之順序無法保留.
16
- * 須比整個相對順序而非只比第一個: 查無實體(群組/權限表內沒有)之名稱會被伺服端 arrFinder 丟棄、編輯器存檔時亦不寫出, 丟棄後實際的第一個 AND 項可能是其後任一名稱(外審 sol#1/minimax#1).
17
- * 有效啟用元素: 名稱為非空字串、isActive 為 'y'、mode 為 OR/AND(同伺服端 arrFinder 以 === 比對名稱、mergeRules 跳過非 OR/AND 者).
18
- * 物件形式(編輯器自己寫出之形式、鍵唯一、鍵序即伺服端序)與其他輸入恆可無損表示, 回傳空陣列. 以原函式 getUserRules 為真理之窮舉驗證見 unit-cvKpToObj PR-001/PR-001b/PR-003
11
+ * - 同名啟用元素之有效模式同時為 OR 與 AND: 物件形式只能留一個 mode. 合併規則為「(所有 OR 聯集) ∩ (所有 AND)」(R-AUTH-MERGE-00), 同名同時為 OR 與 AND 時
12
+ * 該項既擴張聯集又收斂交集, 保留 AND 支會使聯集少了該項(減權), 保留 OR 支則少了一個交集條件(增權), 一律視為有損(此格若整組只有該名稱則碰巧相等, 仍從嚴判定).
13
+ * - 啟用元素之名稱不是字串(陣列元素無 name 等): 物件形式沒有對應的鍵. 該元素仍以懸空引用參與合併(內容全 n; AND 使結果為空, OR 使「無 OR 時以第一個 AND 為基底」失效,
14
+ * R-PRINCIPLE-00 c), 靜默丟棄即放寬, 故拒存(R-PRINCIPLE-00 f 之「無法原樣保留則拒存」; 空字串名稱則可原樣保留為物件鍵 '', 不在此格). 名稱以 `#<序號>`(1 起算)表示.
15
+ *
16
+ * 不判定「該層啟用項皆 mode 缺或非法」(原第三格, ADR-035 決定 e; run06 使用者裁示放寬, ADR-035 Update 2026-10-22): mode 缺或非法之啟用項伺服端只當限制項
17
+ * (R-PRINCIPLE-00 e, 該層無任何 mode 合法之啟用項時結果全 n), 編輯器雖顯示為「AND、啟用」, 但存檔時對未被管理者改動者原樣寫回其原始 mode(editorRefRows 之
18
+ * getRawModeMap/putRowsToKp/putRefEntry, 方案 A; R-EDIT-KEEP-01), 故未改動時存檔前後授權相等、不會成為唯一 AND 基底而多給; 放寬前此格拒存只會使管理者無法存任何變更.
19
+ * 該項之 mode 缺漏提示仍由 getAuthWarn 之 modeInvalid 警告圖示承擔(不變). 管理者在對話框內明確改動(改為 OR/AND、啟用其他項)之結果, 等於伺服端對「改動後之狀態」之解讀,
20
+ * 未被改動之項不放寬(以真 getUserRules 窮舉證明見 unit-editorRows RT-LOSSY3-*).
21
+ *
22
+ * 不再判定「AND 名稱之相對順序」: 合併規則下結果與項目之儲存順序、物件鍵序、陣列元素序無關(R-AUTH-MERGE-00 第 4 條; ADR-033 取代 ADR-001 非對稱條),
23
+ * 整數式名稱被 JS 屬性序排前不再改變授權(原 run02 之排序保護 keepKeyOrder/getKeyOrderLost/getKpWriteLost 隨之移除).
24
+ * 啟用元素: isActive 為 'y' 者(mode 缺或非法者視為 AND 限制項, 見 getEffMode; 同伺服端 mergeRules).
25
+ * 編輯器自己寫出之物件形式(每個名稱有合法 mode)與其他輸入恆可無損表示, 回傳空陣列. 以原函式 getUserRules 為真理之窮舉驗證見 unit-cvKpToObj PR-001/PR-001b/PR-003
19
26
  * (凡存檔前後授權不相等者皆被本函數判定, 漏判 0; 本函數為保守側, 某些整體授權碰巧相等之格仍判有損).
20
27
  *
21
28
  * @param {Object|Array} kp 輸入 JSON5.parse(cgrups/cpemis) 之結果
@@ -24,64 +31,52 @@ import isAuthMode from './isAuthMode.mjs'
24
31
  *
25
32
  * console.log(getKpLossy([{ name: 'G1', mode: 'OR', isActive: 'y' }, { name: 'G1', mode: 'AND', isActive: 'y' }]))
26
33
  * // => ['G1']
34
+ * console.log(getKpLossy([{ name: 'G1', mode: 'OR', isActive: 'y' }, { name: 'G1', isActive: 'y' }]))
35
+ * // => ['G1'] (缺 mode 視為 AND)
36
+ * console.log(getKpLossy([{ mode: 'AND', isActive: 'y' }]))
37
+ * // => ['#1']
27
38
  * console.log(getKpLossy({ G1: { mode: 'OR', isActive: 'y' } }))
28
39
  * // => []
40
+ * console.log(getKpLossy({ G1: { isActive: 'y' } }))
41
+ * // => [] (該層啟用項皆 mode 缺漏: 原第三格, 已放寬)
29
42
  *
30
43
  */
31
44
  function getKpLossy(kp) {
32
- if (!Array.isArray(kp)) {
45
+ if (kp === null || typeof kp !== 'object') {
33
46
  return []
34
47
  }
35
48
 
36
- //seq: 有效啟用元素(依伺服端序)
37
- let seq = []
49
+ //kpModes: 啟用元素之名稱 → 出現過的有效 mode; unnamed: 啟用而名稱非字串之元素
50
+ let kpModes = Object.create(null)
51
+ let unnamed = []
52
+ let k = 0
38
53
  eachOwn(cvKpToArr(kp), (r) => {
39
- if (isestr(r.name) && r.isActive === 'y' && isAuthMode(r.mode)) {
40
- seq.push({ name: r.name, mode: r.mode })
54
+ k++
55
+ if (r.isActive !== 'y') {
56
+ return true //跳出換下一個
41
57
  }
42
- })
43
-
44
- let out = []
45
- let add = (n) => {
46
- if (!out.includes(n)) {
47
- out.push(n)
58
+ if (typeof r.name !== 'string') {
59
+ unnamed.push(`#${k}`)
60
+ return true //跳出換下一個
48
61
  }
49
- }
50
-
51
- //1) 同名同時有效 OR 與 AND
52
- let kpModes = Object.create(null)
53
- eachOwn(seq, (e) => {
54
- if (!(e.name in kpModes)) {
55
- kpModes[e.name] = []
62
+ let m = getEffMode(r.mode)
63
+ if (!(r.name in kpModes)) {
64
+ kpModes[r.name] = []
56
65
  }
57
- if (!kpModes[e.name].includes(e.mode)) {
58
- kpModes[e.name].push(e.mode)
66
+ if (!kpModes[r.name].includes(m)) {
67
+ kpModes[r.name].push(m)
59
68
  }
60
69
  })
70
+
71
+ //同名同時有效 OR 與 AND(僅陣列形式可能), 及啟用而無字串名稱者
72
+ let out = []
61
73
  eachOwn(kpModes, (ms, n) => {
62
74
  if (ms.length > 1) {
63
- add(n)
64
- }
65
- })
66
-
67
- //2) AND 名稱之相對順序於伺服端序與物件鍵序不同
68
- let andOrder = []
69
- eachOwn(seq, (e) => {
70
- if (e.mode === 'AND' && !andOrder.includes(e.name)) {
71
- andOrder.push(e.name)
75
+ out.push(n)
72
76
  }
73
77
  })
74
- if (andOrder.length >= 2) {
75
- let probe = Object.create(null)
76
- eachOwn(andOrder, (n) => {
77
- probe[n] = true
78
- })
79
- if (Object.keys(probe).some((n, i) => n !== andOrder[i])) {
80
- eachOwn(andOrder, add)
81
- }
82
- }
83
78
 
84
- return out
79
+ return [...out, ...unnamed]
85
80
  }
86
81
 
87
82
 
@@ -0,0 +1,194 @@
1
+ import eachOwn from './eachOwn.mjs'
2
+ import findTouchedDup from './findTouchedDup.mjs'
3
+ import getPropKey from './getPropKey.mjs'
4
+ import { isExternalRow } from './externalRows.mjs'
5
+
6
+
7
+ //後台清單頁(群組/權限/使用者)「名稱(或 email)擋存」之判準, 單一擁有者(規則帳本 R-PRINCIPLE-00 g、h, R-NAME-UNIQ-02):
8
+ // 只擋「本次造成或觸及」者, 庫內既有的歷史問題(僅外部同步可造成)改為警告圖示而不擋存——否則已有歷史同名之庫在後台永遠存不了、無從修復.
9
+ // 重複之判準與伺服端同一份(findTouchedDup, server/keyCheck.mjs 亦轉出之), 故「前端判擋存 ⇔ 伺服端回 nameDuplicate」.
10
+ // 純函數, 不依賴 Vue/DOM/語系; 文字由呼叫端依 errBlock/warnHist/warnHistBad 之分類代入($t).
11
+ //
12
+ // 比對一律精確 ===; 以 Map 與無原型物件計, 免原型鍵名(constructor、__proto__)誤判(規則帳本 R-KEY-01).
13
+ // 基準 rowsOld = 最近一次由伺服端載入的列(store 之 grups/pemis/users 原始列); 新增但尚未儲存的列(id 不在基準內)視為本次造成.
14
+
15
+
16
+ /**
17
+ * 取得清單頁某欄(name/email)之擋存錯誤與歷史警告
18
+ *
19
+ * 每一列之該欄值分三類:
20
+ * - 壞值(empty): 不是字串或為空字串; 另 opt.isValid 給予時, 非空字串但不合格式者為 invalid(如 email 格式不符);
21
+ * - 好值之重複: 好值在 rowsNew 內出現 ≥ 2 次; 用 findTouchedDup 判斷是「本次造成或觸及」(dup, 擋存)還是「歷史既有原樣」(warnHist, 不擋存);
22
+ * - 其餘無事.
23
+ * 壞值預設一律擋存(opt.badScope 'all', 群組/權限之名稱: 伺服端 ckKey 對任一列之空/非字串名稱皆拒);
24
+ * opt.badScope 為 'touched' 時只擋「本次新增、或由其他值改成該壞值」之列, 基準內原樣就是壞值之列(歷史空值)不擋、改列入 warnHistBad(使用者之 name/email).
25
+ *
26
+ * 判定前先把兩邊列的有限數字 id 轉為字串(同伺服端), 非陣列視為空.
27
+ * 回傳之物件鍵為「儲存格之值」(非字串者依屬性鍵轉型, 空字串為 ''; 與清單頁以 props.value 查表一致), 皆為無原型物件.
28
+ *
29
+ * @param {Array} rowsNew 輸入目前清單之列(vo.opt.rows, 含未儲存之編修)
30
+ * @param {Array} rowsOld 輸入基準列(store 之原始列; 非陣列視為空)
31
+ * @param {String} [key='name'] 輸入欄位名
32
+ * @param {Object} [opt={}] 輸入設定物件
33
+ * @param {String} [opt.badScope='all'] 輸入壞值之擋存範圍, 'all' 或 'touched'
34
+ * @param {Function} [opt.isValid] 輸入好值格式檢查函數(值 → Boolean), 不給則任一非空字串皆合格
35
+ * @returns {Object} 回傳 { errBlock, warnHist, warnHistBad }:
36
+ * errBlock 值 → 'empty' | 'invalid' | 'dup'(會擋存)
37
+ * warnHist 值 → 目前共用該值之列數(歷史同名, 不擋存; 與 errBlock 互斥, 以擋存者為準)
38
+ * warnHistBad 值 → 'empty' | 'invalid'(歷史壞值, 不擋存; 僅 badScope 為 'touched' 時有)
39
+ * dupExternal 值 → 外部列之來源 from(僅 errBlock 為 'dup' 者, 且共用該值之列中有外部列〔from 為非空字串, externalRows.isExternalRow〕; 供提示改為「外部來源 X 已使用此名稱」, 否則無此鍵)
40
+ * @example
41
+ *
42
+ * let rowsOld = [{ id: '1', name: 'A' }, { id: '2', name: 'A' }]
43
+ * let r1 = getNameErrs(rowsOld, rowsOld, 'name')
44
+ * console.log(r1.errBlock.A, r1.warnHist.A)
45
+ * // => undefined 2
46
+ * let r2 = getNameErrs([...rowsOld, { id: '3', name: 'A' }], rowsOld, 'name')
47
+ * console.log(r2.errBlock.A, r2.warnHist.A)
48
+ * // => 'dup' undefined
49
+ *
50
+ */
51
+ function getNameErrs(rowsNew0, rowsOld0, key = 'name', opt = {}) {
52
+ let o = (opt !== null && typeof opt === 'object') ? opt : {}
53
+ let touchedOnly = o.badScope === 'touched'
54
+ let isValid = typeof o.isValid === 'function' ? o.isValid : null
55
+
56
+ let errBlock = Object.create(null)
57
+ let warnHist = Object.create(null)
58
+ let warnHistBad = Object.create(null)
59
+ let dupExternal = Object.create(null)
60
+
61
+ let isRow = (r) => r !== null && typeof r === 'object'
62
+
63
+ //id 正規化: 有限數字 id 轉為字串(同伺服端 procCore.updateTabItems 於 findTouchedDup 之前所做, R-ORM-04), 其餘不變; 非陣列視為空.
64
+ //why: 前端若直接以數字 id 比對庫內之字串 id 會認不得同一列, 把歷史同名誤判為本次造成而擋存(兩端不一致)
65
+ let normIds = (rows) => {
66
+ if (!Array.isArray(rows)) {
67
+ return []
68
+ }
69
+ return rows.map((r) => {
70
+ return (isRow(r) && typeof r.id === 'number' && Number.isFinite(r.id)) ? { ...r, id: String(r.id) } : r
71
+ })
72
+ }
73
+ let rowsNew = normIds(rowsNew0)
74
+ let rowsOld = normIds(rowsOld0)
75
+
76
+ //基準: id → 基準列該欄之原始值
77
+ let kpOldVal = new Map()
78
+ eachOwn(rowsOld, (r) => {
79
+ if (isRow(r) && typeof r.id === 'string') {
80
+ kpOldVal.set(r.id, r[key])
81
+ }
82
+ })
83
+
84
+ //rowsNew: 壞值分類; 好值計次(Map: 值 → 列數)
85
+ let cnt = new Map()
86
+ eachOwn(rowsNew, (r) => {
87
+ if (!isRow(r)) {
88
+ return true //跳出換下一個
89
+ }
90
+ let raw = r[key]
91
+ let v = (raw === undefined) ? '' : raw //缺欄位視為空字串(同清單頁既有 get(v, 'name', ''))
92
+ let kind = null
93
+ if (typeof v !== 'string' || v.length === 0) {
94
+ kind = 'empty'
95
+ }
96
+ else if (isValid !== null && !isValid(v)) {
97
+ kind = 'invalid'
98
+ }
99
+ if (kind === null) {
100
+ cnt.set(v, (cnt.get(v) || 0) + 1)
101
+ return true //跳出換下一個
102
+ }
103
+ let kc = getPropKey(v) //儲存格值轉屬性鍵, 轉不了者(如 { toString: null })無從在畫面標示, 略過
104
+ if (kc === null) {
105
+ return true //跳出換下一個
106
+ }
107
+ //本次造成或觸及: 新列(id 不在基準)、或該欄值與基準不同
108
+ let touched = !(typeof r.id === 'string' && kpOldVal.has(r.id) && kpOldVal.get(r.id) === raw)
109
+ if (!touchedOnly || touched) {
110
+ errBlock[kc] = kind
111
+ }
112
+ else {
113
+ warnHistBad[kc] = kind
114
+ }
115
+ })
116
+
117
+ //好值之重複: 同值各列與基準中同值各列交給 findTouchedDup(兩端同一份判準)
118
+ let dupVals = []
119
+ cnt.forEach((n, v) => {
120
+ if (n >= 2) {
121
+ dupVals.push(v)
122
+ }
123
+ })
124
+ if (dupVals.length > 0) {
125
+ let setDup = new Set(dupVals)
126
+ let kpNew = new Map()
127
+ let kpOld = new Map()
128
+ eachOwn(rowsNew, (r) => {
129
+ if (isRow(r) && setDup.has(r[key])) {
130
+ if (!kpNew.has(r[key])) {
131
+ kpNew.set(r[key], [])
132
+ }
133
+ kpNew.get(r[key]).push(r)
134
+ }
135
+ })
136
+ eachOwn(rowsOld, (r) => {
137
+ if (isRow(r) && setDup.has(r[key])) {
138
+ if (!kpOld.has(r[key])) {
139
+ kpOld.set(r[key], [])
140
+ }
141
+ kpOld.get(r[key]).push(r)
142
+ }
143
+ })
144
+ eachOwn(dupVals, (v) => {
145
+ let kc = getPropKey(v)
146
+ if (kc === null) {
147
+ return true //跳出換下一個
148
+ }
149
+ let k = findTouchedDup(kpNew.get(v), kpOld.get(v) || [], key)
150
+ if (k >= 0) {
151
+ errBlock[kc] = 'dup'
152
+ let rExt = kpNew.get(v).find((r) => isExternalRow(r)) //另一方(或其中一方)為外部列: 提示改為「外部來源 X 已使用此名稱」
153
+ if (rExt !== undefined) {
154
+ dupExternal[kc] = rExt.from
155
+ }
156
+ }
157
+ else {
158
+ warnHist[kc] = cnt.get(v)
159
+ }
160
+ })
161
+ }
162
+
163
+ //以擋存者為準
164
+ eachOwn(Object.keys(errBlock), (kc) => {
165
+ delete warnHist[kc]
166
+ delete warnHistBad[kc]
167
+ })
168
+
169
+ return {
170
+ errBlock,
171
+ warnHist,
172
+ warnHistBad,
173
+ dupExternal,
174
+ }
175
+ }
176
+
177
+
178
+ /**
179
+ * 把「值 → 分類(或筆數)」之無原型物件逐項轉為「值 → 文字」之無原型物件(清單頁以 props.value 查表), 供 computed 組 tooltip 文字用
180
+ *
181
+ * @param {Object} kp 輸入 getNameErrs 回傳之 errBlock/warnHist/warnHistBad
182
+ * @param {Function} fn 輸入轉換函數(分類或筆數, 值) → 文字
183
+ * @returns {Object} 回傳無原型物件
184
+ */
185
+ function mapNameKp(kp, fn) {
186
+ let out = Object.create(null)
187
+ eachOwn(kp, (v, k) => {
188
+ out[k] = fn(v, k)
189
+ })
190
+ return out
191
+ }
192
+
193
+
194
+ export { getNameErrs, mapNameKp }
package/src/js/getOwn.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //授權與資料歸屬之查值: 供權限評估(src/plugins/mShare.mjs)、外部應用 SDK(src/perm.mjs)、後台整表寫入(server/procCore.mjs)與編輯對話框(VeCgrups/VeCpemis/VeCrules)共用
2
- //規則見 CLAUDE_rulebook.md「迭代使用端傳入之物件」之 R-AUTH-PATH-01
2
+ //規則見 sgud/rule/rulebook.md「迭代使用端傳入之物件」之 R-AUTH-PATH-01
3
3
  //本檔無外部相依
4
4
 
5
5
 
@@ -0,0 +1,37 @@
1
+ //資料驅動之名稱轉屬性鍵: 供權限評估(src/plugins/mShare.mjs mergeRules)與編輯對話框(經 cvKpToObj)共用
2
+ //規則見 sgud/rule/rulebook.md「關聯對話框容錯、以身分配對、資料驅動屬性鍵」之 R-KEY-PROP-01
3
+ //本檔無外部相依
4
+
5
+
6
+ /**
7
+ * 把資料驅動之名稱轉為屬性鍵字串(與 `obj[name] = …` 之屬性鍵轉型相同), 轉不了者回 null
8
+ *
9
+ * 外部應用可直接寫入陣列形式授權資料([{ "name": …, "isActive": … }]), 元素 name 為任意 JSON 值; 以之當屬性鍵時,
10
+ * JS 先將物件轉為字串(ToPrimitive → toString/valueOf), 遇 `{ "toString": null }`、`[{ "toString": 1 }]` 等值必拋 TypeError
11
+ * (Cannot convert object to primitive value), 若不接住會使整個權限查詢失敗. 本函數與屬性鍵轉型取同一個 ToString:
12
+ * 可轉者回同一字串(5 → '5'、true → 'true'、null → 'null'、['T1'] → 'T1'、{} → '[object Object]'), 轉不了者回 null, 呼叫端略過該元素(不對應任何標的/規則, 等同不存在).
13
+ * 例外(Symbol, JSON 不會產生): 屬性鍵轉型可用(建立 symbol 鍵, 不被 Object.keys/eachOwn 走訪), 本函數回 null; 兩者效果相同(該元素不對應任何字串名稱之標的/規則).
14
+ *
15
+ * @param {*} name 輸入名稱(任意值)
16
+ * @returns {String|null} 回傳屬性鍵字串; 轉型會拋錯者回傳 null
17
+ * @example
18
+ *
19
+ * console.log(getPropKey('T1'))
20
+ * // => 'T1'
21
+ * console.log(getPropKey(5))
22
+ * // => '5'
23
+ * console.log(getPropKey({ toString: null }))
24
+ * // => null
25
+ *
26
+ */
27
+ function getPropKey(name) {
28
+ try {
29
+ return `${name}`
30
+ }
31
+ catch (err) {
32
+ return null
33
+ }
34
+ }
35
+
36
+
37
+ export default getPropKey
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 判斷是否為合法合併模式 'OR' 或 'AND'
3
3
  *
4
- * 與伺服端 src/plugins/mShare.mjs 之 mergeRules 一致: mode 非 OR/AND 之群組/權限不納入合併(不授權).
5
- * 編輯器解碼(cvKpToObj)與無損表示判定(getKpLossy)共用, 單一擁有者.
4
+ * 用於偵測 mode 缺或不合法(頁面提示 getAuthWarn 之 modeInvalid). 伺服端 src/plugins/mShare.mjs 之 mergeRules 對 mode 非 OR/AND 之項
5
+ * 不再略過而是視為 AND 限制項(規則帳本 R-AUTH-MERGE-00 第 5 條、R-PRINCIPLE-00 e), 有效值見 getEffMode.
6
6
  *
7
7
  * @param {*} m 輸入 mode
8
8
  * @returns {Boolean} 回傳是否為 'OR' 或 'AND'
@@ -0,0 +1,84 @@
1
+ //反向關聯對話框(VeGrupBlngUsers/VePemiBlngGrups)之「列 ↔ 資料快照」身分配對
2
+ //規則見 sgud/rule/rulebook.md「關聯對話框容錯、以身分配對、資料驅動屬性鍵」之 R-REL-PAIR-01
3
+ //本檔無外部相依
4
+
5
+
6
+ //toId: 身分值正規化——非空字串,或有限數字(ADR-028: 數字主鍵以其字串形式處置);其餘(空字串、NaN、物件、null、undefined)視為無身分
7
+ function toId(v) {
8
+ if (typeof v === 'string' && v !== '') {
9
+ return v
10
+ }
11
+ if (typeof v === 'number' && Number.isFinite(v)) {
12
+ return String(v)
13
+ }
14
+ return null
15
+ }
16
+
17
+
18
+ /**
19
+ * 把對話框之列以「列身分」配對到存檔當下取得之資料快照(不用列索引)
20
+ *
21
+ * 反向關聯對話框(群組頁「所屬使用者」、權限頁「所屬群組」)開啟時依 store 當時的列序產生對話框的列; 存檔時重新取 store 快照.
22
+ * 兩者以列索引配對時, store 於開框期間被更新(另一位管理者增刪列或改 order)即錯位, 關聯寫到另一個使用者/群組(規則帳本 R-REL-PAIR-01).
23
+ * 本函數以身分欄位(預設 id)配對: 每列配到快照中身分相同(且 verifyKeys 欄位值全相等)的列; 配不到者列入 missing,
24
+ * 由呼叫端整次拒存並提示重新整理, 不得退而配到其他列.
25
+ *
26
+ * @param {Array} rows 輸入對話框之列, 每列帶身分欄位(opt.idKey); 非陣列時回傳空結果
27
+ * @param {Array} snapshot 輸入存檔當下取得之資料表快照(列物件陣列); 缺身分欄位之快照列不參與配對, 身分重複者取第一筆
28
+ * @param {Object} [opt={}] 輸入設定物件
29
+ * @param {String} [opt.idKey='id'] 輸入身分欄位名稱, 預設'id'
30
+ * @param {Array} [opt.verifyKeys=[]] 輸入除身分外另須與快照相等之欄位名稱陣列(如群組之 'name': 改名視為過期), 預設[]
31
+ * @returns {Object} 回傳 { pairs, missing, krs, matched }; pairs 與 rows 等長, pairs[kr] 為配到之快照列(同一個物件參考),配不到為 null;
32
+ * missing 為配不到之列(原物件)陣列; krs 為配得到之列的列索引(依列序), matched[i] 為 krs[i] 那一列配到之快照列(兩者等長、同序, 供「只處理配得到者」之顯示重算用, 免呼叫端自行壓縮索引)
33
+ * @example
34
+ *
35
+ * let snapshot = [{ id: 'u2', name: 'b' }, { id: 'u1', name: 'a' }]
36
+ * let rows = [{ id: 'u1', name: 'a' }, { id: 'u3', name: 'c' }]
37
+ * let { pairs, missing, krs } = pairRowsToSnapshot(rows, snapshot)
38
+ * console.log(pairs[0].name, pairs[1], missing.length, krs)
39
+ * // => 'a' null 1 [0]
40
+ *
41
+ */
42
+ function pairRowsToSnapshot(rows, snapshot, opt = {}) {
43
+ let pairs = []
44
+ let missing = []
45
+ let krs = []
46
+ let matched = []
47
+ if (!Array.isArray(rows)) {
48
+ return { pairs, missing, krs, matched }
49
+ }
50
+ let idKey = (opt !== null && typeof opt === 'object' && typeof opt.idKey === 'string' && opt.idKey !== '') ? opt.idKey : 'id'
51
+ let verifyKeys = (opt !== null && typeof opt === 'object' && Array.isArray(opt.verifyKeys)) ? opt.verifyKeys : []
52
+
53
+ //kp: 身分 → 快照列(Map: 身分為 __proto__/constructor 等字串時亦精確)
54
+ let kp = new Map()
55
+ if (Array.isArray(snapshot)) {
56
+ for (let it of snapshot) {
57
+ let id = (it !== null && typeof it === 'object') ? toId(it[idKey]) : null
58
+ if (id !== null && !kp.has(id)) {
59
+ kp.set(id, it)
60
+ }
61
+ }
62
+ }
63
+
64
+ for (let kr = 0; kr < rows.length; kr++) { //索引迴圈(不用 forEach: 稀疏陣列之空洞須視為無身分列入 missing, pairs 與 rows 等長)
65
+ let r = rows[kr]
66
+ let id = (r !== null && typeof r === 'object') ? toId(r[idKey]) : null
67
+ let it = (id === null) ? undefined : kp.get(id)
68
+ if (it !== undefined && verifyKeys.every((k) => r[k] === it[k])) {
69
+ pairs.push(it)
70
+ krs.push(kr)
71
+ matched.push(it)
72
+ }
73
+ else {
74
+ pairs.push(null)
75
+ missing.push(r)
76
+ }
77
+ }
78
+
79
+ return { pairs, missing, krs, matched }
80
+ }
81
+
82
+
83
+ export { toId }
84
+ export default pairRowsToSnapshot