import path from 'path'
import fs from 'fs'
import genID from 'wsemi/src/genID.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import execProcess from 'wsemi/src/execProcess.mjs'
//透過connMDB.exe(one-shot)操作Access mdb
//exe使用Windows內建Jet 4.0引擎(32-bit)與內建.NET Framework 4.x, 目標機不需安裝任何東西
//協定: 寫入fpIn(JSON)後呼叫exe, exe執行完寫fpRes(JSON), 查詢結果逐列寫jsonl檔
//Jet引擎之錯誤編號, 由exe取自OleDbError之SQLState(如3022重複鍵, 3058主鍵為Null, 3464型別不符)
//以數字判定錯誤類別而不比對錯誤訊息文字, 因該訊息隨系統語系而異
//註: 3022涵蓋主鍵與其他唯一索引之衝突, 兩者無從由錯誤碼區辨,
// 故呼叫端若須確認係主鍵衝突, 仍應另行核對該主鍵是否確實存在
const CODE_DUP_KEY = 3022 //重複鍵: 主鍵或唯一索引衝突
//連線開啟失敗之重試設定
//Jet以.ldb鎖檔協調多連線存取, 而本套件為one-shot(每次呼叫各自開啟與關閉連線),
//高頻開關下多行程或同行程多連線併發時, 該鎖檔之競爭會間歇令開啟失敗(Jet 3734),
//已實機重現: 未重試時跨行程併發save出現寫入遺失, 單行程useStable為false時出現逐筆失敗。
//此類失敗發生於連線開啟階段, 該次呼叫尚未執行任何指令(由exe之openFailed標記保證,
//exe於開啟失敗時一律中止其後cmds), 故重試整批為安全且不會重複套用寫入。
//僅白名單錯誤碼重試(3734為已實測之鎖競爭, 3050為鎖檔無法建立),
//密碼錯誤/檔案不存在/格式不符等永久性開啟失敗不重試, 直接回報
const nTryOpen = 8
const msTryOpen = 25
const codesTryOpen = [3734, 3050]
/**
* 等待指定毫秒
*
* @ignore
*/
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function getFdExe() {
//fdSrv
let fdSrv = path.resolve()
//fnExe
let fnExe = `connMDB.exe`
//fdExe
let fdExe = ''
if (true) {
let fdExeSrc = `${fdSrv}/src/`
let fdExeNM = `${fdSrv}/node_modules/w-orm-mdb/src/`
if (fsIsFile(`${fdExeSrc}${fnExe}`)) {
fdExe = fdExeSrc
}
else if (fsIsFile(`${fdExeNM}${fnExe}`)) {
fdExe = fdExeNM
}
else {
return null
}
}
return {
fdExe,
prog: `${fdExe}${fnExe}`,
}
}
/**
* 開啟mdb操作物件
*
* @param {Object} [db={}] 輸入資料庫設定物件
* @param {String} [db.path] 輸入mdb檔案位置字串
* @param {String} [db.password=''] 輸入資料庫密碼字串, 空字串代表無密碼
* @returns {Object} 回傳操作物件{run,query,execute,create}
*/
function jetOpen(db) {
/**
* 低階執行, 一次呼叫exe跑完全部cmds
*
* 註: 僅於exe未產出結果檔時reject; 個別cmd之失敗一律經results逐筆回報而不reject,
* 令呼叫端得以errorCode判別衝突與其他錯誤並各自處置
* 註: 連線開啟失敗(openFailed)且錯誤碼屬可重試之鎖競爭類時整批重試, 見codesTryOpen之說明;
* 重試耗盡或不可重試者, 回傳之結果物件仍帶openFailed為true, 供呼叫端辨識為整批性錯誤
*
* @param {Array} cmds 輸入指令陣列, 各項為{type,sql,stopOnError}
* @param {Object} [opt={}] 輸入設定物件
* @param {Boolean} [opt.useTransaction=false] 輸入是否將整批cmds包在單一交易內, 任一失敗即回滾
* @returns {Promise} 回傳Promise,resolve回傳{ok,error,stopped,rolledBack,openFailed,results}
*/
async function run(cmds, opt = {}) {
//整批重試, 僅限[開啟失敗且零語句已執行]且錯誤碼屬鎖競爭類者
let res = null
for (let i = 0; i < nTryOpen; i++) {
//runOnce
res = await runOnce(cmds, opt)
//check, 非開啟失敗即定案
if (res.openFailed !== true) {
break
}
//check, 永久性開啟失敗(密碼錯誤/檔案不存在/格式不符)不重試, 直接回報
let ec = ((res.results || [])[0] || {}).errorCode
if (codesTryOpen.indexOf(ec) < 0) {
break
}
//delay, 線性退避
if (i + 1 < nTryOpen) {
await delay(msTryOpen * (i + 1))
}
}
return res
}
/**
* 執行一次exe呼叫
*
* @ignore
*/
async function runOnce(cmds, opt = {}) {
//fdExe
let r = getFdExe()
if (r === null) {
return Promise.reject('can not find folder for connMDB')
}
let { fdExe, prog } = r
//useTransaction
let useTransaction = opt.useTransaction === true
//id
let id = genID()
//fpIn, fpRes
let fpIn = `${fdExe}_${id}_in.json`
let fpRes = `${fdExe}_${id}_res.json`
//fpOuts, 為query類指令配置jsonl輸出檔, 以cmd索引為鍵以免因失敗略過而錯位
let fpOuts = {}
cmds = cmds.map((c, k) => {
if (c.type === 'query') {
let fpOut = `${fdExe}_${id}_q${k}.jsonl`
fpOuts[k] = fpOut
return { ...c, fpOut }
}
return c
})
//save
fs.writeFileSync(fpIn, JSON.stringify({ db, useTransaction, cmds }), 'utf8')
//execProcess
let errTemp = null
await execProcess(prog, [fpIn, fpRes])
.catch((err) => {
errTemp = err.toString()
})
//read res
let res = null
try {
res = JSON.parse(fs.readFileSync(fpRes, 'utf8'))
}
catch (err) {}
//read query results, 僅該筆query成功者方有輸出檔
if (res !== null) {
res.results = (res.results || []).map((rr, k) => {
if (cmds[k] && cmds[k].type === 'query' && rr.ok) {
let rows = []
try {
let c = fs.readFileSync(fpOuts[k], 'utf8')
let lines = c.split('\n')
for (let i = 0; i < lines.length; i++) {
let ln = lines[i]
if (ln !== '') {
rows.push(JSON.parse(ln))
}
}
}
catch (err) {}
return { ...rr, rows }
}
return rr
})
}
//unlink temp files
let fps = [fpIn, fpRes, ...Object.values(fpOuts)]
fps.forEach((fp) => {
try {
fs.unlinkSync(fp)
}
catch (err) {}
})
//check, exe未產出結果檔
if (res === null) {
return Promise.reject(errTemp || 'connMDB.exe did not produce result file')
}
return res
}
/**
* 執行單一指令並於失敗時reject, 供只關心成敗之呼叫點使用
*
* @ignore
*/
async function runOne(cmd, opt = {}) {
let res = await run([cmd], opt)
if (!res.ok) {
return Promise.reject(res.error)
}
return res.results[0]
}
return {
//低階, 多指令一次往返, 逐筆結果
run,
//查詢, 回傳數據陣列
query: async (sql) => {
let rr = await runOne({ type: 'query', sql })
return rr.rows
},
//執行(insert/update/delete), 回傳{ok,n}
execute: async (sql) => {
let rr = await runOne({ type: 'execute', sql })
return rr
},
//建立新mdb檔(db.password非空則建立加密檔)
create: async () => {
let rr = await runOne({ type: 'create' })
return rr
},
}
}
export default jetOpen
export { CODE_DUP_KEY }