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
@@ -6,8 +6,10 @@ import fetchJson from '../src/fetchJson.mjs'
6
6
 
7
7
 
8
8
  //kpKs
9
+ //perm-run03 起外部匯入依來源鍵 srcKey 差異同步(R-IMPORT-01、dspec/外部匯入之定義與機制.md): 每列須帶外部自訂之 srcKey(同一 from 內唯一且穩定), **不得送出 id**(內部主鍵由本系統產生, 送 id 整批拒收 idNotAllowed);
10
+ //targets 之 id 即標的名稱, 外部匯入時由 srcKey 衍生(id=srcKey), 故 targets 亦只送 srcKey
9
11
  let ks_targets = [
10
- 'id',
12
+ 'srcKey',
11
13
  'order', //order大部分由外部給予, 得要開啟
12
14
  'description',
13
15
  'from',
@@ -17,7 +19,7 @@ let ks_targets = [
17
19
  // 'timeUpdate',
18
20
  ]
19
21
  let ks_pemis = [
20
- 'id',
22
+ 'srcKey',
21
23
  'order', //order大部分由外部給予, 得要開啟
22
24
  'name',
23
25
  'description',
@@ -29,7 +31,7 @@ let ks_pemis = [
29
31
  // 'timeUpdate',
30
32
  ]
31
33
  let ks_grups = [
32
- 'id',
34
+ 'srcKey',
33
35
  'order', //order大部分由外部給予, 得要開啟
34
36
  'name',
35
37
  'description',
@@ -41,7 +43,7 @@ let ks_grups = [
41
43
  // 'timeUpdate',
42
44
  ]
43
45
  let ks_users = [
44
- 'id',
46
+ 'srcKey',
45
47
  'order', //order大部分由外部給予, 得要開啟
46
48
  'name',
47
49
  'email',
@@ -0,0 +1,461 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import isearr from 'wsemi/src/isearr.mjs'
4
+ import clearXSS from 'wsemi/src/clearXSS.mjs'
5
+ import genIDSeq from 'wsemi/src/genIDSeq.mjs'
6
+ import ltdtmapping from 'wsemi/src/ltdtmapping.mjs'
7
+ import ds from '../src/schema/index.mjs'
8
+ import validateRowsBeforeWrite, { maxIdBytes } from './validateRows.mjs'
9
+ import planRefChanges, { REF_OF, diffRefTargets } from '../src/js/refCascade.mjs'
10
+ import { SERVER_FIELDS } from '../src/js/externalRows.mjs'
11
+ import { findDupIndex, findKeyOtherFrom, withTabLocks, mapKeyOf, checkMapKeyBatch, findLoginIdClashDetail } from './keyCheck.mjs'
12
+ import { checkCascadePlan, writeWithCascade } from './cascadeWrite.mjs'
13
+ import { markHasKey, keepAssignOnMissing, keepAssignByEntity, countSrcKeyRenumber } from './syncKeep.mjs'
14
+ import { toOutKey, isRowObj } from './procCore.mjs'
15
+
16
+
17
+ //外部匯入(外部同步)之核心: 依外部自訂之來源鍵 srcKey 於表鎖內做差異同步(perm-run03 範圍二; 使用者 2026-10-22 裁示原則 11、2026-10-23 裁示不提供呼叫端相容;
18
+ //規格(真理源) C:\opensrcOper\dspec\外部匯入之定義與機制.md 第 3~10 節; 規則帳本 R-IMPORT-01).
19
+ //由 WWebPerm.mjs 之 POST /syncAndReplaceTabs 呼叫; 獨立成模組以便注入 ORM 替身做單元測試(寫入中途失敗之補償、鎖、配對).
20
+ //
21
+ //語意: 本文 { from, rows }, rows=該 from 之完整清單; 每列帶外部自訂之 srcKey(同一 from 內唯一且穩定), 不得帶 id(內部主鍵由本系統產生).
22
+ // 於該表之表鎖內(與後台整表寫入、引用連帶之引用端寫入共鎖, R-WRITE-LOCK-01/02)新鮮讀取庫內全表, 依 srcKey 配對該 from 之庫內列:
23
+ // 新增: 配新 id(targets 例外: 其 id 即標的名稱〔被 pemis.crules 以名稱引用〕, 由 srcKey 衍生, id=srcKey);
24
+ // 修改: 保留 id, 只寫外部擁有欄位與有帶鍵之外部可選欄位(指派欄), 改名之引用跟著改(R-REF-CASCADE-01);
25
+ // 刪除: 庫有、批次無 → 刪除, 引用依原則 6 連帶清理;
26
+ // 不變: 不寫(零寫入; 冪等).
27
+ // 後台列(from 為空字串)與他 from 之列永不被觸及. 回 { nAdd, nUpdate, nDel, nSame }.
28
+ //欄位歸屬: 外部擁有(批次提供之欄位, 缺鍵視為空值)、外部可選(指派欄 users.cgrups/grups.cpemis/pemis.crules: 有帶鍵才寫、缺鍵保留庫內值, F-23, syncKeep.mjs)、
29
+ // 本系統擁有(伺服器管理欄: 建立者/建立時間/更新者/更新時間, 一律不採批次值). id、srcKey、from 為身分欄: 由本函式寫入, 後台不可改(procCore/externalRows 守門).
30
+ //一次性資料遷移(非呼叫端相容): 既有外部列(舊同步寫入、無 srcKey)於首次匯入時, 批次 srcKey 等於其 id 者視為同一列, 於鎖內補上 srcKey; 其後只認 srcKey, 對不上者依差異同步刪舊增新.
31
+ //寫入中途失敗: 先預檢(整批驗證、引用預檢)攔下可預見之失敗; 寫入階段失敗時補償還原——引用端由 writeWithCascade 還原, 本表由 undoMain 以鎖內讀取之舊列還原(新增者刪除、被刪者與被改者以整列覆寫寫回);
32
+ // 補償亦失敗 → saveWriteFail + srLog(syncAndReplaceTabs-restore-error), 由稽核工具人工處置.
33
+
34
+
35
+ let hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
36
+ let isEmptyV = (v) => v === undefined || v === null || v === ''
37
+
38
+
39
+ //eqV: 同步比對用相等(缺欄/null/'' 視為同一空值; 純量以字串形式比, 數字 1 與字串 '1' 同一〔ORM 欄位型別轉換不致使冪等失效〕; 物件以 JSON 比)
40
+ let eqV = (a, b) => {
41
+ if (isEmptyV(a) || isEmptyV(b)) {
42
+ return isEmptyV(a) && isEmptyV(b)
43
+ }
44
+ if (typeof a === 'object' || typeof b === 'object') {
45
+ return JSON.stringify(a) === JSON.stringify(b)
46
+ }
47
+ return String(a) === String(b)
48
+ }
49
+
50
+
51
+ //isSrcKey: 來源鍵須為非空白字串、UTF-8 位元組數不超過 ORM 主鍵長度上限(targets 之 id 即 srcKey, 受 LMDB 主鍵上限約束; 其餘表統一同一上限)、不含 HTML 標記(會被 clearXSS 改寫者; 送入值即儲存值)
52
+ let isSrcKey = (s) => typeof s === 'string' && s.trim() !== '' && Buffer.byteLength(s, 'utf8') <= maxIdBytes && clearXSS(s) === s
53
+
54
+
55
+ //KEY_ASSIGN: 各表之授權指派欄(外部可選); targets 無
56
+ let KEY_ASSIGN = { users: 'cgrups', grups: 'cpemis', pemis: 'crules', targets: null }
57
+
58
+
59
+ /**
60
+ * 建立外部匯入核心
61
+ *
62
+ * @param {Object} p 輸入參數物件
63
+ * @param {Object} p.woItems 輸入 ORM 表物件集合(select/save/delAll)
64
+ * @param {Function} p.procOrm 輸入 procOrm(userId, 表名, 方法, 資料, opt)
65
+ * @param {Function} p.lockTabs 輸入 w-serv-orm 實例之 withTabLocks(tableNames, fn)
66
+ * @param {Object} p.srLog 輸入 srLog
67
+ * @param {String} [p.mappingBy='id'] 輸入使用者對應鍵('id'|'email'|'name')
68
+ * @param {Boolean} [p.cascadeRename=true] 輸入改名是否連帶更新引用
69
+ * @returns {Object} 回傳 { syncAndReplaceTabs(userId, inp, keyTable), getSyncWarns() }
70
+ */
71
+ function syncCore({ woItems, procOrm, lockTabs, srLog, mappingBy = 'id', cascadeRename = true }) {
72
+
73
+
74
+ //syncWarns: 外部同步「同一 from 同名異 srcKey」(疑似來源鍵重編)之提示登錄(記憶體, 重啟即清; 主控定案: 不加開關, 記 warn log 並使頁面可提示):
75
+ //以「表名+來源」為鍵, 最近一次同步有此情形者登錄筆數與時間, 已無此情形者清除; 只含表名、來源名稱、筆數、時間, 不含個資.
76
+ //why 保留(perm-run03 範圍二 5 評估): 差異同步下身分是 srcKey, 「同名異 id」之 id 重編已不存在, 但外部來源若重編 srcKey(如目錄重建後 GUID 全換), 該列等於「刪舊增新」,
77
+ //引用每次被連帶清理而抹掉後台指派(原則 6: 新實體不繼承)——此風險仍在, 提示仍有價值, 故判定口徑由 id 改為 srcKey(countSrcKeyRenumber), 不移除
78
+ let kpSyncWarn = new Map()
79
+ let setSyncWarn = (keyTable, from, n) => {
80
+ let k = `${keyTable}\u0000${from}`
81
+ if (n > 0) {
82
+ kpSyncWarn.set(k, { keyTable, from, n, time: new Date().toISOString() })
83
+ }
84
+ else {
85
+ kpSyncWarn.delete(k)
86
+ }
87
+ }
88
+ let getSyncWarns = () => [...kpSyncWarn.values()]
89
+
90
+
91
+ //syncAndReplaceTabs
92
+ let syncAndReplaceTabs = async (userId, inp, keyTable) => {
93
+
94
+ //序列化同表之並行匯入/後台整表寫入: 防 lost update (同 keyTable 序列化, 不同表並行); 與後台整表寫入(procCore.updateTabItems)共用同一個鎖管理器(w-serv-orm 實例之 withTabLocks, R-LOCK-CORE-01);
95
+ //有改名或刪除而須改寫引用端時, 於內層依階層再取引用端表之鎖(R-REF-CASCADE-01、R-WRITE-LOCK-02). 外部匯入為不帶基準之寫入(以外部為主, 原則 5): 取表鎖但不比對基準
96
+ return await withTabLocks(lockTabs, [keyTable], async () => {
97
+
98
+ let logErr = (msg, extra = {}) => srLog.error({ event: 'syncAndReplaceTabs-error', msg, keyTable, ...extra })
99
+
100
+ //from
101
+ let from = get(inp, 'from', '')
102
+ if (!isestr(from)) {
103
+ logErr('invalidFrom')
104
+ return Promise.reject(`invalidFrom`)
105
+ }
106
+
107
+ //rows: 空批次拒收(清空某來源須另以明確操作, 防誤清源, ADR-008)
108
+ let rowsIn = get(inp, 'rows', [])
109
+ if (!isearr(rowsIn)) {
110
+ logErr('invalidRows')
111
+ return Promise.reject(`invalidRows`)
112
+ }
113
+
114
+ //各列須為非空物件(規則帳本 R-ORM-05, 與後台整表寫入共用 isRowObj)
115
+ let kBadRow = rowsIn.findIndex((v) => !isRowObj(v))
116
+ if (kBadRow >= 0) {
117
+ logErr('invalidRows', { reason: 'rowInvalid', from, row: kBadRow })
118
+ return Promise.reject(`invalidRows`)
119
+ }
120
+
121
+ //idNotAllowed: 內部主鍵 id 由本系統產生, 外部不得指定(含空值; 規格第 9 節): 任一列帶有 id 鍵 → 整批拒收、零寫入. 先於 srcKey 檢查——
122
+ //仍送 id 者多為舊呼叫方式(以 id 代 srcKey), 先提示移除 id, 再提示補 srcKey
123
+ let kId = rowsIn.findIndex((v) => hasOwn(v, 'id'))
124
+ if (kId >= 0) {
125
+ logErr('idNotAllowed', { from, row: kId, field: 'id' })
126
+ return Promise.reject(`idNotAllowed`)
127
+ }
128
+
129
+ //srcKey 必填(規格第 5 節): 缺、空白、非字串、過長、含標記 → srcKeyInvalid; 同批重複 → srcKeyDuplicate; 皆整批拒收、零寫入
130
+ let kSrc = rowsIn.findIndex((v) => !isSrcKey(v.srcKey))
131
+ if (kSrc >= 0) {
132
+ logErr('srcKeyInvalid', { from, row: kSrc, field: 'srcKey' })
133
+ return Promise.reject(`srcKeyInvalid`)
134
+ }
135
+ let kSrcDup = findDupIndex(rowsIn, 'srcKey')
136
+ if (kSrcDup >= 0) {
137
+ logErr('srcKeyDuplicate', { from, row: kSrcDup, field: 'srcKey' })
138
+ return Promise.reject(`srcKeyDuplicate`)
139
+ }
140
+
141
+ //keyAssign/hasAssign: 外部可選之指派欄及各列是否自帶該鍵(須於 ltdtmapping 缺鍵補 '' 之前記錄); 缺該鍵者保留庫內值(F-23, 見 syncKeep.mjs)
142
+ let keyAssign = KEY_ASSIGN[keyTable]
143
+ let hasAssign = (keyAssign !== null) ? markHasKey(rowsIn, keyAssign) : null
144
+
145
+ //rows: 映射到 schema 欄位(缺鍵補 ''; 不在 schema 之鍵剝除), 寫入 from; targets 之 id 即標的名稱, 由 srcKey 衍生(id=srcKey)
146
+ let rows = ltdtmapping(rowsIn.map((v) => {
147
+ let o = { ...v, from }
148
+ if (keyTable === 'targets') {
149
+ o.id = o.srcKey
150
+ }
151
+ return o
152
+ }), ds[keyTable].keys)
153
+
154
+ //名稱須為非空字串(規則帳本 R-SYNC-NAME-01): grups/pemis 之 name 缺漏、空字串、非字串一律整批拒收(targets 之 id 即 srcKey, 已於上方保證非空字串; users 之 name 可空)
155
+ let keyName = (keyTable === 'grups' || keyTable === 'pemis') ? 'name' : null
156
+ if (keyName !== null) {
157
+ let kName = rows.findIndex((v) => !isestr(get(v, keyName)))
158
+ if (kName >= 0) {
159
+ logErr('invalidRows', { reason: 'nameInvalid', from, row: kName, field: keyName })
160
+ return Promise.reject(`invalidRows`)
161
+ }
162
+
163
+ //群組/權限名稱唯一(F-02, 規則帳本 R-NAME-UNIQ-01): 本批內 name 精確 === 重複整批拒收(他 from/後台列已使用者見下方 nameOtherFrom)
164
+ let kDup = findDupIndex(rows, 'name')
165
+ if (kDup >= 0) {
166
+ logErr('invalidRows', { reason: 'nameDuplicate', from, row: kDup, field: 'name' })
167
+ return Promise.reject(`invalidRows`)
168
+ }
169
+ }
170
+
171
+ //users 對應鍵批內檢查(F-14, 規則帳本 R-MAPKEY-01): mappingBy 為 'email'/'name' 時, 每個 isActive==='y' 之列該欄位須非空字串且批內唯一; log 不記對應值(個資)
172
+ if (keyTable === 'users') {
173
+ let mk = checkMapKeyBatch(rows, mappingBy)
174
+ if (mk !== null) {
175
+ logErr('invalidRows', { reason: mk.reason, from, row: mk.k, field: mappingBy })
176
+ return Promise.reject(`invalidRows`)
177
+ }
178
+ }
179
+
180
+ //寫入前驗證(規則帳本 R-ORM-01): 主鍵(targets 之 id)長度/標記、任一字串欄位含會被 clearXSS 改寫之標記 → htmlMarkupNotAllowed; 其餘 → invalidRows
181
+ let ve = validateRowsBeforeWrite(rows)
182
+ if (ve !== null) {
183
+ let key = (ve.reason === 'htmlMarkup') ? 'htmlMarkupNotAllowed' : 'invalidRows'
184
+ logErr(key, { reason: ve.reason, from, row: ve.k, field: ve.field })
185
+ return Promise.reject(key)
186
+ }
187
+
188
+ //rsAll: 庫內全表(表鎖內新鮮讀取, 後續配對、跨 from 檢查、引用規劃共用同一版本); 讀取失敗尚未寫入任何資料, 不回 saveWriteFail
189
+ let rsAll = null
190
+ try {
191
+ rsAll = await woItems[keyTable].select()
192
+ }
193
+ catch (err) {
194
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
195
+ let key = toOutKey(err, 'anUnexpectedErrorOccurred')
196
+ logErr(key, { raw, from, step: 'select' })
197
+ return Promise.reject(key)
198
+ }
199
+ let objs = rsAll.filter((r) => r !== null && typeof r === 'object')
200
+ let sameFrom = objs.filter((r) => r.from === from)
201
+ let others = objs.filter((r) => r.from !== from)
202
+
203
+ //跨 from 同名/同對應鍵(F-02/F-14, 規則帳本 R-NAME-UNIQ-01/R-MAPKEY-01): grups/pemis 之 name、users 於 mappingBy 為 email/name 時之該欄位(僅 active 列),
204
+ //已為其他 from(含後台列)之既有列使用 → 整批拒絕(雙方不變、提示); 同 from 之既有列不算衝突(同批比對後被修改或刪除)
205
+ let keyOther = (keyName === 'name') ? 'name' : ((keyTable === 'users') ? mapKeyOf(mappingBy) : null)
206
+ if (keyOther !== null) {
207
+ let kOther = findKeyOtherFrom(rows, objs, from, keyOther, { onlyActive: keyTable === 'users' })
208
+ if (kOther >= 0) {
209
+ logErr('invalidRows', { reason: (keyTable === 'users') ? 'mapKeyOtherFrom' : 'nameOtherFrom', from, row: kOther, field: keyOther })
210
+ return Promise.reject('invalidRows')
211
+ }
212
+ }
213
+
214
+ //users 登入識別撞值(mappingBy 為 'id' 時; perm-run04, 規則帳本 R-MAPKEY-04, 規格第 9 節末 O1): 外部列以 srcKey、後台列以 id 對應外部 token 之使用者 id.
215
+ //perm-run05(主控 2026-10-24 收件裁示): srcKey 須跨全表唯一, 「不論 isActive」「含他來源外部列」——本批任一列(皆視為被觸及, 同 R-MAPKEY-01: 批次全列參與)之 srcKey 等於他 from 之任一既有列
216
+ //(後台列之 id、他來源外部列之 srcKey、他來源舊外部列〔無 srcKey〕之 id)→ 登入將命中多筆 → 整批拒收 loginIdCollision(收嚴+提示, 提示請部署方改用可唯一對應之鍵; 零寫入); 同 from 之既有列不算(隨後由差異同步配對或刪除).
217
+ //固定訊息、log 不記 srcKey/id 之值
218
+ if (keyTable === 'users' && mapKeyOf(mappingBy) === null) {
219
+ let dClash = findLoginIdClashDetail([...others, ...rows], (r, k) => k >= others.length)
220
+ if (dClash !== null) {
221
+ logErr('loginIdCollision', { reason: (dClash.kind === 'otherFrom') ? 'srcKeyOtherFrom' : 'srcKeyIsAdminId', from, row: dClash.k - others.length, field: 'srcKey' })
222
+ return Promise.reject('loginIdCollision')
223
+ }
224
+ }
225
+
226
+ //配對(依 srcKey): 庫內該 from 之列有 srcKey 者依其值對應; 無 srcKey 者為遷移前資料(舊同步寫入), 批次 srcKey 等於其 id 者視為同一列並補上 srcKey(一次性資料遷移, 規格第 9 節);
227
+ //庫內同 from 有重複 srcKey, 或同一 srcKey 同時被兩個庫內列主張(一個有 srcKey、一個以 id 主張)→ 無法唯一配對, 整批拒收(srcKeyConflict), 以稽核/清源工具人工處置
228
+ let oldBySrc = new Map()
229
+ let legacyById = new Map()
230
+ for (let r of sameFrom) {
231
+ if (isestr(r.srcKey)) {
232
+ if (oldBySrc.has(r.srcKey)) {
233
+ logErr('srcKeyConflict', { reason: 'dupInDb', from })
234
+ return Promise.reject('srcKeyConflict')
235
+ }
236
+ oldBySrc.set(r.srcKey, r)
237
+ }
238
+ else if (isestr(r.id)) {
239
+ legacyById.set(r.id, r)
240
+ }
241
+ }
242
+ for (let k of oldBySrc.keys()) {
243
+ if (legacyById.has(k)) { //同一 srcKey 被兩個庫內列主張(一個已有 srcKey、一個以 id 主張): 與庫內重複 srcKey 同屬庫內資料異常, 不論本批是否帶該鍵一律拒收(外審 sol#2)
244
+ logErr('srcKeyConflict', { reason: 'claimTwice', from })
245
+ return Promise.reject('srcKeyConflict')
246
+ }
247
+ }
248
+ let matched = rows.map((v) => {
249
+ let o = oldBySrc.get(v.srcKey)
250
+ if (o === undefined && legacyById.has(v.srcKey)) {
251
+ return { old: legacyById.get(v.srcKey), migrate: true }
252
+ }
253
+ return o === undefined ? { old: null, migrate: false } : { old: o, migrate: false }
254
+ })
255
+
256
+ //各列之身分 id: 配對者沿用庫內 id(不變); 新增者配新 id(targets 為 srcKey 衍生之標的名稱——主鍵已被庫內任何其他列占用者整批拒收, 免 insert 對既有主鍵靜默略過; 其餘表產生不撞既有之循序 id)
257
+ let usedIds = new Set(objs.map((r) => r.id))
258
+ let idOf = []
259
+ for (let k = 0; k < rows.length; k++) {
260
+ let m = matched[k]
261
+ if (m.old !== null) {
262
+ idOf.push(m.old.id)
263
+ continue
264
+ }
265
+ if (keyTable === 'targets') {
266
+ if (usedIds.has(rows[k].id)) {
267
+ logErr('invalidRows', { reason: 'idTaken', from, row: k, field: 'srcKey' }) //標的名稱(id)已被其他來源或後台列使用: 與他 from 同鍵(R-SYNC-01 精神)
268
+ return Promise.reject('invalidRows')
269
+ }
270
+ idOf.push(rows[k].id)
271
+ }
272
+ else {
273
+ let nid = `${genIDSeq()}`
274
+ while (usedIds.has(nid)) {
275
+ nid = `${genIDSeq()}`
276
+ }
277
+ idOf.push(nid)
278
+ }
279
+ usedIds.add(idOf[k])
280
+ }
281
+
282
+ //外部可選之指派欄: payload 缺該鍵之列保留庫內值(F-23; 指派欄屬後台, 同步不得清掉後台指派; 含該鍵則以外部為準). 身分已由 srcKey 配對(rowsKeep 之 id 即配對到之庫內 id, 新增者為新配之 id),
283
+ //故 syncKeep 以 id 認列; users 另依實際 mappingBy 做登入對應鍵矛盾檢查(外審 sol#1): mappingBy 為 email/name 時登入以該欄位找使用者, 同 srcKey 之對應鍵值被改成別值、或新值為庫內別列持有,
284
+ //保留指派會使該登入值取得原擁有者之授權(換主移轉, 增權), 一律不保留並記 warn(收嚴+提示, 同 perm-run03 之前的行為); 改名而 cascadeRename 為假者不保留(keepAssignByEntity, 引用不跟著改時名稱即授權身分)
285
+ let rowsKeep = rows.map((v, k) => ({ ...v, id: idOf[k] }))
286
+ if (keyAssign !== null && hasAssign.some((b) => b === false)) {
287
+ //isKeepable: 保留值須能通過與外部寫入同一套寫入前驗證(外審 sol R4): 不通過者不保留(視為空, 收嚴), 只記筆數
288
+ let isKeepable = (val) => validateRowsBeforeWrite([{ id: 'keep', [keyAssign]: val }]) === null
289
+ let kr = (keyTable === 'users') ? keepAssignOnMissing({ rows: rowsKeep, hasKey: hasAssign, rowsOldSameFrom: sameFrom, mappingBy, isKeepable }) : keepAssignByEntity({ rows: rowsKeep, hasKey: hasAssign, rowsOldSameFrom: sameFrom, key: keyAssign, cascadeRename, isKeepable })
290
+ rowsKeep = kr.rows
291
+ if (kr.nKeep > 0) {
292
+ srLog.info({ event: 'syncAndReplaceTabs-keepAssign', keyTable, from, field: keyAssign, n: kr.nKeep })
293
+ }
294
+ if (kr.nSkip > 0) {
295
+ srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nSkip, reason: 'notKeepable' })
296
+ }
297
+ if (kr.nConflict > 0) {
298
+ srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nConflict, reason: 'identityConflict' }) //改名而引用不跟著改、或 users 登入對應鍵矛盾(email/name 被改或為別列持有): 指派不移轉而清空(收嚴須附提示; 不含個資值)
299
+ }
300
+ }
301
+
302
+ //差異分類: 外部擁有欄位(schema 欄位扣除 id/srcKey/from/伺服器管理欄/指派欄; 含 order——外部列之順序由外部擁有)與指派欄(缺鍵者已於上方換成庫內值)任一不同、或需補 srcKey(遷移)即修改; 皆同即不變
303
+ let keysExt = ds[keyTable].keys.filter((k) => k !== 'id' && k !== 'srcKey' && k !== 'from' && k !== keyAssign && SERVER_FIELDS.indexOf(k) < 0)
304
+ let keysCmp = keyAssign !== null ? [...keysExt, keyAssign] : keysExt
305
+ let adds = []
306
+ let upds = []
307
+ let updPre = []
308
+ let nSame = 0
309
+ let nMigrate = 0
310
+ let postById = new Map() //修改後之列(供規劃改名), 以庫內 id 為鍵
311
+ rowsKeep.forEach((v, k) => {
312
+ let m = matched[k]
313
+ if (m.old === null) {
314
+ adds.push({ ...v, id: idOf[k], srcKey: v.srcKey, from })
315
+ return
316
+ }
317
+ let o = m.old
318
+ let isDiff = m.migrate || keysCmp.some((f) => !eqV(v[f], o[f]))
319
+ if (!isDiff) {
320
+ nSame++
321
+ return
322
+ }
323
+ if (m.migrate) {
324
+ nMigrate++
325
+ }
326
+ let w = { ...o }
327
+ keysCmp.forEach((f) => {
328
+ w[f] = v[f]
329
+ })
330
+ w.srcKey = v.srcKey
331
+ w.from = from
332
+ upds.push(w)
333
+ updPre.push({ ...o })
334
+ postById.set(o.id, w)
335
+ })
336
+ let matchedIds = new Set(matched.filter((m) => m.old !== null).map((m) => m.old.id))
337
+ let dels = sameFrom.filter((r) => !matchedIds.has(r.id))
338
+
339
+ //引用連帶(適用表 m/n, R-REF-CASCADE-01): 以「寫入後整表」=他 from 之列+同 from 存續者(含修改後)+新增者, 與寫入前整表比對——同 id 異名=改名級聯(srcKey 同、名稱變)、
340
+ //舊 id 不在寫入後整表=刪除而連帶清理引用(寫入後仍存續之同名者不清理). 預檢(新名撞懸空名、歷史同名組改名、容器內相撞、改寫後欄位驗證)於任何寫入之前, 被拒整批零變更.
341
+ //是否須改寫引用端只依本表新舊列判定(diffRefTargets), 持有本表鎖之下即可決定是否再取引用端表之鎖(R-WRITE-LOCK-02); 取齊鎖之後才讀取引用端、規劃與預檢
342
+ let rowsPlan = null
343
+ let kind = null
344
+ if (hasOwn(REF_OF, keyTable)) {
345
+ let survivors = sameFrom.filter((r) => matchedIds.has(r.id)).map((r) => postById.get(r.id) || r)
346
+ rowsPlan = { rowsOld: objs, rowsNew: [...others, ...survivors, ...adds] }
347
+ kind = diffRefTargets({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, opt: { cascadeRename } })
348
+
349
+ //外部來源鍵重編之偵測(見檔頭 syncWarns): 只適用有 name 之 grups/pemis(users 之 name 可重複, targets 之 id 即 srcKey)
350
+ if (keyTable === 'grups' || keyTable === 'pemis') {
351
+ let nRenum = countSrcKeyRenumber(sameFrom, rows)
352
+ setSyncWarn(keyTable, from, nRenum)
353
+ if (nRenum > 0) {
354
+ srLog.warn({ event: 'syncAndReplaceTabs-srcKeyRenumber', keyTable, from, n: nRenum })
355
+ }
356
+ }
357
+ }
358
+
359
+ let result = { nAdd: adds.length, nUpdate: upds.length, nDel: dels.length, nSame }
360
+ let info = () => srLog.info({ event: 'syncAndReplaceTabs-diff', keyTable, from, userId, ...result, nMigrate })
361
+
362
+ //無任何變更: 零寫入(冪等; 表之基準不變)
363
+ if (adds.length === 0 && upds.length === 0 && dels.length === 0) {
364
+ info()
365
+ return result
366
+ }
367
+
368
+ //writeMain: 刪除 → 新增 → 修改(同後台整表寫入之次序); 任一步失敗即以鎖內讀取之舊列補償還原本表(逐步冪等, 不論做到哪一步皆可無條件全做): 新增者以 id 刪除、被刪者與被改者以整列覆寫寫回(upsert, merge:false);
369
+ //還原失敗 → saveWriteFail + srLog; 還原成功後重新拋出原錯誤(外層轉 saveWriteFail 並記 raw)
370
+ let writeMain = async () => {
371
+ try {
372
+ if (dels.length > 0) {
373
+ await procOrm(userId, keyTable, 'del', dels, { mustExist: true })
374
+ }
375
+ if (adds.length > 0) {
376
+ await procOrm(userId, keyTable, 'insert', adds)
377
+ }
378
+ if (upds.length > 0) {
379
+ await procOrm(userId, keyTable, 'save', upds, { mustExist: true })
380
+ }
381
+ }
382
+ catch (err) {
383
+ let okUndo = true
384
+ try {
385
+ if (adds.length > 0) {
386
+ await woItems[keyTable].delAll({ id: { $in: adds.map((a) => a.id) } })
387
+ }
388
+ let back = [...dels, ...updPre].map((r) => ({ ...r }))
389
+ if (back.length > 0) {
390
+ let rs = await woItems[keyTable].save(back, { merge: false })
391
+ if (Array.isArray(rs) ? rs.some((v) => v && v.ok === 0) : (rs !== null && typeof rs === 'object' && rs.ok === 0)) { //逐筆結果陣列(或單一結果物件)有 ok:0 即還原失敗(外審 beam#1)
392
+ okUndo = false
393
+ }
394
+ }
395
+ }
396
+ catch (errUndo) {
397
+ okUndo = false
398
+ }
399
+ if (!okUndo) {
400
+ srLog.error({ event: 'syncAndReplaceTabs-restore-error', msg: 'saveWriteFail', keyTable, from, userId, nAdd: adds.length, nUpdate: upds.length, nDel: dels.length })
401
+ return Promise.reject('saveWriteFail')
402
+ }
403
+ srLog.warn({ event: 'syncAndReplaceTabs-restored', keyTable, from, userId, nAdd: adds.length, nUpdate: upds.length, nDel: dels.length })
404
+ return Promise.reject(err)
405
+ }
406
+ }
407
+
408
+ //doWrite: ORM/I/O 失敗之原文只入 srLog, 外部應用收 saveWriteFail(規則帳本 R-ORM-02); 引用端先寫、失敗補償還原(cascadeWrite.mjs)
409
+ let doWrite = async (plan, holdersOld) => {
410
+ try {
411
+ await writeWithCascade({ plan, holdersOld, woItems, procOrm, userId, srLog, writeMain })
412
+ }
413
+ catch (err) {
414
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
415
+ let key = toOutKey(err)
416
+ if (key === 'saveCascadePartial') {
417
+ key = 'saveWriteFail' //引用端補償失敗(雙重故障): 同步端點之固定錯誤契約為 saveWriteFail(規格 §5.6); 引用端懸空由 cascadeWrite 之 refCascade-restore-error 供稽核(外審 sol#3)
418
+ }
419
+ logErr(key, { raw, from })
420
+ return Promise.reject(key)
421
+ }
422
+ info()
423
+ return result
424
+ }
425
+
426
+ //有改名或刪除: 再取引用端表之鎖, 讀取引用端、規劃、預檢後寫入
427
+ if (kind !== null && (kind.renames.length > 0 || kind.delNames.length > 0)) {
428
+ let holder = REF_OF[keyTable].holder
429
+ return await withTabLocks(lockTabs, [holder], async () => {
430
+ let holdersOld = null
431
+ try {
432
+ holdersOld = await woItems[holder].select()
433
+ }
434
+ catch (err) {
435
+ let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
436
+ let key = toOutKey(err, 'anUnexpectedErrorOccurred')
437
+ logErr(key, { raw, from, step: 'select' })
438
+ return Promise.reject(key)
439
+ }
440
+ let plan = planRefChanges({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, holdersOld, opt: { cascadeRename } })
441
+ let errPlan = plan.err !== null ? plan.err : checkCascadePlan(plan)
442
+ if (errPlan !== null) {
443
+ let key = (errPlan === 'htmlMarkupNotAllowed') ? 'htmlMarkupNotAllowed' : 'invalidRows'
444
+ logErr(key, { reason: errPlan, from })
445
+ return Promise.reject(key)
446
+ }
447
+ return await doWrite(plan, holdersOld)
448
+ }, [keyTable])
449
+ }
450
+
451
+ return await doWrite(null, [])
452
+
453
+ })
454
+ }
455
+
456
+
457
+ return { syncAndReplaceTabs, getSyncWarns }
458
+ }
459
+
460
+
461
+ export default syncCore
@@ -1,7 +1,7 @@
1
- //外部同步(WWebPerm.mjs:syncAndReplaceTabs)之三個純函數(F-23; R-PRINCIPLE-00 原則 3、5; 規則帳本 R-EXT-OWN-01; ADR-037), 不依賴 ORM/Vue, 供單元測試:
1
+ //外部同步(server/syncCore.mjs:syncAndReplaceTabs; perm-run03 起依 srcKey 差異同步, 本檔函數由其以「配對到之庫內 id/新配之 id」呼叫)之三個純函數(F-23; R-PRINCIPLE-00 原則 3、5; 規則帳本 R-EXT-OWN-01; ADR-037), 不依賴 ORM/Vue, 供單元測試:
2
2
  // keepAssignOnMissing: users 列「payload 缺 cgrups 鍵」時保留庫內指派(指派欄屬後台, Lv1; 含該鍵則以外部為準)——修正前 ltdtmapping 缺鍵補 ''、delAll+insert 即清掉後台已指派之群組(run06 已知限制④);
3
3
  // keepAssignByEntity: grups 列缺 cpemis 鍵、pemis 列缺 crules 鍵時同樣保留(run08; 同為後台可寫之指派欄)——群組/權限無登入對應鍵, 實體身分依 id(無 id 者依名稱), 見該函數說明;
4
- // countIdRenumber: 同一 from 同一批內出現「同名異 id」(疑似外部 id 重編)之筆數, 供 warn log 與頁面提示(外部 id 不穩定時, id 消失=刪除會每次清理引用而抹掉後台指派, 不加開關, 以提示處理)
4
+ // countSrcKeyRenumber: 同一 from 同一批內出現「同名異 srcKey」(疑似外部來源鍵重編)之筆數, 供 warn log 與頁面提示(外部來源鍵不穩定時, 來源鍵消失=刪除會每次清理引用而抹掉後台指派, 不加開關, 以提示處理; 原 countIdRenumber 之 id 口徑隨差異同步改為 srcKey)
5
5
 
6
6
 
7
7
  let isStr = (v) => typeof v === 'string'
@@ -202,33 +202,40 @@ function keepAssignByEntity({ rows, hasKey, rowsOldSameFrom, key, cascadeRename
202
202
 
203
203
 
204
204
  /**
205
- * 同一 from 同一批內「同名異 id」之筆數(疑似外部 id 重編): 本批列帶 id 與其名稱, 而庫內同一 from 有另一列同名、不同 id、且該舊 id 不在本批
205
+ * 同一 from 同一批內「同名異 srcKey」之筆數(疑似外部來源鍵重編; perm-run03 範圍二: 差異同步下身分是 srcKey, 原「同名異 id」之判定口徑改為 srcKey)
206
+ *
207
+ * 本批列之 srcKey 不屬庫內同一 from 任何列(新來源鍵), 而庫內同一 from 有另一列同名、其來源鍵不在本批(將被刪除)——外部來源若重編來源鍵(如目錄重建後 GUID 全換),
208
+ * 該列等於「刪舊增新」, 引用每次被連帶清理而抹掉後台指派(原則 6: 新實體不繼承). 庫內尚無 srcKey 之列(遷移前資料)以其 id 為身分候選(批次 srcKey 等於其 id 者視為同一列, 見 syncCore).
206
209
  *
207
210
  * @param {Array} rowsOldSameFrom 輸入庫內同一 from 之既有列(grups/pemis; users 之 name 可重複故不適用)
208
- * @param {Array} rows 輸入本批列(帶 id 者才計)
211
+ * @param {Array} rows 輸入本批列(須帶 srcKey 與 name)
209
212
  * @returns {Number} 回傳筆數
210
213
  */
211
- function countIdRenumber(rowsOldSameFrom, rows) {
212
- let idsBatch = new Set()
214
+ function countSrcKeyRenumber(rowsOldSameFrom, rows) {
215
+ let idOf = (o) => (isStr(o.srcKey) && o.srcKey !== '') ? o.srcKey : (isStr(o.id) ? o.id : null) //庫內列之身分候選: 有 srcKey 用 srcKey, 遷移前資料用 id
216
+ let keysBatch = new Set()
213
217
  rows.forEach((v) => {
214
- let id = toId(v)
215
- if (id !== null) {
216
- idsBatch.add(id)
218
+ if (isObj(v) && isStr(v.srcKey)) {
219
+ keysBatch.add(v.srcKey)
217
220
  }
218
221
  })
219
- let oldByName = new Map() //名稱 → 本批未沿用之舊 id 集合
222
+ let keysOld = new Set()
223
+ let goneByName = new Set() //將被刪除之庫內列之名稱(其身分不在本批)
220
224
  rowsOldSameFrom.forEach((o) => {
221
- if (isObj(o) && isStr(o.id) && isStr(o.name) && !idsBatch.has(o.id)) {
222
- if (!oldByName.has(o.name)) {
223
- oldByName.set(o.name, new Set())
224
- }
225
- oldByName.get(o.name).add(o.id)
225
+ if (!isObj(o)) {
226
+ return
227
+ }
228
+ let k = idOf(o)
229
+ if (k !== null) {
230
+ keysOld.add(k)
231
+ }
232
+ if (k !== null && !keysBatch.has(k) && isStr(o.name)) {
233
+ goneByName.add(o.name)
226
234
  }
227
235
  })
228
236
  let n = 0
229
237
  rows.forEach((v) => {
230
- let id = toId(v)
231
- if (id !== null && isObj(v) && isStr(v.name) && oldByName.has(v.name)) {
238
+ if (isObj(v) && isStr(v.srcKey) && !keysOld.has(v.srcKey) && isStr(v.name) && goneByName.has(v.name)) {
232
239
  n++
233
240
  }
234
241
  })
@@ -236,4 +243,4 @@ function countIdRenumber(rowsOldSameFrom, rows) {
236
243
  }
237
244
 
238
245
 
239
- export { markHasKey, keepAssignOnMissing, keepAssignByEntity, countIdRenumber }
246
+ export { markHasKey, keepAssignOnMissing, keepAssignByEntity, countSrcKeyRenumber }
@@ -133,6 +133,32 @@ function formatReport(rp, opt = {}) {
133
133
  }
134
134
 
135
135
 
136
+ /**
137
+ * 兩個路徑是否指向同一個檔案(供 --clean-out 不得覆寫輸入檔之判定): 先比解析後之絕對路徑(僅 Windows 不分大小寫; 其他平台大小寫敏感, 僅大小寫不同者可為不同檔),
138
+ * 兩者皆已存在時再比實際路徑(realpath: 解開符號連結與父目錄別名; 檔案系統不提供可用 inode 時仍能辨識符號連結別名), 最後以 inode 與 dev 判定硬連結
139
+ *
140
+ * @param {String} fpA 路徑 A
141
+ * @param {String} fpB 路徑 B
142
+ * @param {Function} [statFn=fs.statSync] 輸入取得檔案資訊之函數(單元測試用,簽章同 fs.statSync)
143
+ * @param {String} [platform=process.platform] 輸入平台字串(單元測試用), 'win32' 時路徑字串比對不分大小寫
144
+ * @returns {Boolean} 回傳是否為同一檔
145
+ */
146
+ function isSameFile(fpA, fpB, statFn = fs.statSync, platform = process.platform) {
147
+ let norm = (p) => (platform === 'win32' ? p.toLowerCase() : p)
148
+ let same = norm(path.resolve(fpA)) === norm(path.resolve(fpB))
149
+ if (!same && fs.existsSync(fpB) && fs.existsSync(fpA)) {
150
+ same = norm(fs.realpathSync.native(fpA)) === norm(fs.realpathSync.native(fpB))
151
+ if (!same) {
152
+ //須以 bigint 取 inode: Number 只有 53 位元精度, NTFS 檔案識別碼(序號<<48|記錄號)超過 2**53 時, 相鄰記錄之不同檔會得到相同 Number 而被誤判為同一檔(perm-run01 範圍三, RA-037 偶發失敗之根因)
153
+ let a = statFn(fpA, { bigint: true })
154
+ let b = statFn(fpB, { bigint: true })
155
+ same = a.ino !== 0n && a.ino === b.ino && a.dev === b.dev
156
+ }
157
+ }
158
+ return same
159
+ }
160
+
161
+
136
162
  //CLI
137
163
  if (isMain(import.meta.url)) {
138
164
  let args = process.argv.slice(2)
@@ -145,13 +171,7 @@ if (isMain(import.meta.url)) {
145
171
  }
146
172
  //輸出不得覆寫輸入(同一檔; 以解析後之絕對路徑與已存在檔之 inode 判定): --clean-out 只含變更列之 id 與引用欄位, 覆寫匯出檔會使其遺失其餘列與欄位
147
173
  if (cleanOut !== undefined) {
148
- let same = path.resolve(fp).toLowerCase() === path.resolve(cleanOut).toLowerCase()
149
- if (!same && fs.existsSync(cleanOut) && fs.existsSync(fp)) {
150
- let a = fs.statSync(fp)
151
- let b = fs.statSync(cleanOut)
152
- same = a.ino !== 0 && a.ino === b.ino && a.dev === b.dev
153
- }
154
- if (same) {
174
+ if (isSameFile(fp, cleanOut)) {
155
175
  console.error('失敗: --clean-out 不得與輸入匯出檔為同一檔(會覆寫原匯出檔)')
156
176
  process.exit(2)
157
177
  }
@@ -172,4 +192,4 @@ if (isMain(import.meta.url)) {
172
192
  }
173
193
 
174
194
 
175
- export { buildReport, formatReport, cleanOutOf }
195
+ export { buildReport, formatReport, cleanOutOf, isSameFile }