w-orm-lmdb 1.0.19 → 1.0.21

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.
@@ -28,13 +28,15 @@
28
28
 
29
29
  ### T2 回傳型別與鍵集合固定
30
30
 
31
- 同一函數不論走哪條路徑,回傳之**型別與鍵集合完全相同**。呼叫端不得需要先判斷某個鍵是否存在。
31
+ 回傳之**型別與鍵集合**由「函數」與「呼叫端明確給定之 option 取值」唯一決定;同一組合下不論走哪條內部路徑、不論數據內容為何,型別與鍵集合完全相同。呼叫端不得需要先判斷某個鍵是否存在。
32
32
 
33
33
  - 計數欄位(`n`、`nInserted`、`nModified`、`nDeleted`)在該函數的規格表中一旦列出即**恆出現**,無對應行為時填 `0`,不得省略。
34
34
  - 逐筆函數(`save`、`del`)恆回傳與輸入**等長**之陣列,即使輸入為單一物件亦回傳長度為 `1` 之陣列。
35
- - 整批函數(`insert`、`insertBulk`、`delAll`)恆回傳單一物件。
35
+ - 整批函數(`insert`、`insertBulk`、`delAll`)恆回傳單一物件;惟 `insert` 於 `option.returnList` 開啟時改回逐筆陣列,見該函數規格。
36
36
  - 唯一的例外是 `err`:僅在該筆 `ok` 為 `0` 時出現,`ok` 為 `1` 時不得出現。
37
37
 
38
+ option 對回傳形式之切換須為**靜態**:呼叫點寫死取值即知回傳形狀,不得設計成依執行結果或數據內容而變。共用之結果處理程式碼不得跨「不同 option 取值之呼叫點」混用——兩種取值即兩份契約。
39
+
38
40
  ### T3 `n` 之定義
39
41
 
40
42
  `n` 為**本次操作於資料庫端所命中或涉及之筆數**。逐函數定義如下,跨套件不得有第二種解讀:
@@ -49,6 +51,8 @@
49
51
 
50
52
  `n` 不得取全表筆數,不得為與結果無關之常數。
51
53
 
54
+ `insert` 於 `option.returnList` 開啟時之逐筆元素,其 `n` 恆為 `1`——該筆主鍵或為命中既有、或經插入而產生,對齊本表 `save`(逐筆)之定義;資訊由 `nInserted` 承載。
55
+
52
56
  ### T4 `ok` 與錯誤處置
53
57
 
54
58
  `ok` 僅有兩值:`1` 成功、`0` 該筆失敗。
@@ -56,10 +60,12 @@
56
60
  - 成功路徑一律 `ok: 1`。**不得**由驅動層之確認旗標(如 MongoDB 之 `acknowledged`、SQL driver 之連線狀態)直接推導——該類旗標會產生沒有錯誤訊息的 `ok: 0`,呼叫端無從處理。若確實需要反映驅動層的未確認狀態,須將其視為該筆失敗,回 `ok: 0` 並附 `err`。
57
61
  - `ok: 0` 僅出現於逐筆函數(`save`、`del`),且**必附 `err` 字串**說明原因。
58
62
  - 單筆失敗**不中斷整批**:其餘筆數照常處理,整批仍 resolve,該筆以 `ok: 0` 回報。
59
- - 整批性錯誤(連線失敗、參數型別錯誤、資料表不存在、權限不足等)以 `Promise.reject` 拋出,不進入逐筆結果。
63
+ - 整批性錯誤(連線失敗、實例已關閉、參數型別錯誤、資料表不存在、權限不足等)以 `Promise.reject` 拋出,不進入逐筆結果。
60
64
 
61
65
  判別「該筆失敗」與「整批性錯誤」的原則:錯誤只影響該筆資料者為前者,影響後續所有筆數者為後者。
62
66
 
67
+ **實例已關閉屬整批性錯誤,且須於各函數入口快速失敗。** 關閉為終態,後續操作必然失敗,故不得空轉等待至逾時,亦不得以正常空結果(`null`、未命中、空陣列)回應——後者使「已關閉」與「查無資料」同形,去重類呼叫端將把每筆判為未存在而靜默 fail-open(整批重送下游昂貴動作),此為該類機制最不能發生的失效方向。
68
+
63
69
  ### T5 輸入無效之處置
64
70
 
65
71
  「輸入無效」指傳入之 `data` 既非有效物件亦非有效陣列。此時不視為錯誤,回傳空結果:
@@ -67,6 +73,7 @@
67
73
  | 函數 | 回傳 |
68
74
  |---|---|
69
75
  | `insert` | `{ n: 0, nInserted: 0, ok: 1 }` |
76
+ | `insert`(`returnList` 開啟) | `[]` |
70
77
  | `insertBulk` | `{ n: 0, nInserted: 0, ok: 1 }` |
71
78
  | `save` | `[]` |
72
79
  | `del` | `[]` |
@@ -84,7 +91,7 @@
84
91
 
85
92
  `autoGenPk: false` 之定位為**依賴注入**:主鍵的產生規則改由呼叫端掌握(如採用外部發號器、以業務欄位組合、沿用上游系統既有識別碼),套件不介入。採用此設定後,**主鍵之唯一性、格式與是否與既有資料衝突,皆由呼叫端自負**;套件不做補救,亦不因主鍵不合預期而額外檢查或修正。
86
93
 
87
- `autoGenPk` 為建構層設定,**不得於 `insert`/`save` 之 `option` 逐次覆寫**。主鍵由誰產生是資料所有權的歸屬,屬整個資料表的政策;若逐次可改,同一資料表將混入兩種來源之主鍵而難以追溯。
94
+ `autoGenPk` 為建構層設定,**不得於各寫入函數之 `option` 逐次覆寫**。主鍵由誰產生是資料所有權的歸屬,屬整個資料表的政策;若逐次可改,同一資料表將混入兩種來源之主鍵而難以追溯。
88
95
 
89
96
  `autoGenPk` 為 `true` 時,自動產生之主鍵值須具足夠唯一性(如 UUID 或單調序列),不得採用可預期碰撞之來源。
90
97
 
@@ -98,7 +105,7 @@
98
105
 
99
106
  採用本例外者為**完全不提供該設定**,而非提供一個恆為 `false` 之設定——後者會讓呼叫端誤以為可切換,且徒增一個讀了文件仍不能用的選項。
100
107
 
101
- 此類套件之 `insert` 與 `save` 於輸入未帶有效主鍵時,一律以 `Promise.reject` 拋出整批性錯誤,行為等同 `autoGenPk` 為 `false`;主鍵之唯一性與格式同樣由呼叫端自負。
108
+ 此類套件之 `insert`、`insertBulk` 與 `save` 於輸入未帶有效主鍵時,一律以 `Promise.reject` 拋出整批性錯誤,行為等同 `autoGenPk` 為 `false`;主鍵之唯一性與格式同樣由呼叫端自負。
102
109
 
103
110
  採用本例外之套件,須於下方「各套件符合狀態」載明其主鍵欄位、所依據之情形與理由;未載明者一律適用 `autoGenPk` 預設為 `true` 之規定。
104
111
 
@@ -255,10 +262,12 @@ err 字串
255
262
 
256
263
  「命中」之判定基準須與 `insert`、`save`、`del` 內對既有數據之認定一致,不得出現 `selectByPk` 回傳物件而 `insert` 仍視為不存在之矛盾。
257
264
 
258
- ### insert(data) → `Promise<{ n, nInserted, ok }>`
265
+ ### insert(data, option) → `Promise<{ n, nInserted, ok } | Array<{ n, nInserted, ok }>>`
259
266
 
260
267
  **僅於主鍵不存在時寫入,已存在者跳過且不覆寫。**
261
268
 
269
+ 預設(`option.returnList` 未給或為 `false`)回傳單一聚合物件:
270
+
262
271
  | 欄位 | 值 |
263
272
  |---|---|
264
273
  | `n` | 輸入筆數 |
@@ -270,6 +279,25 @@ err 字串
270
279
  - 須符合 T7 之原子性要求。
271
280
  - 輸入無效見 T5。
272
281
 
282
+ #### `option.returnList`(預設 `false`):改回逐筆結果
283
+
284
+ 聚合計數回答「有幾筆是新的」,回答不了「**是哪幾筆**」——而後者正是去重之產出物(下游僅對新資料執行昂貴動作)。缺少逐筆結果時,呼叫端唯一出路是把批次退化為單筆呼叫、以 `nInserted === 1` 反推,令套件之批次寫入優化完全失效。本選項將函數內部本就算出之逐筆判定交出來。
285
+
286
+ `returnList` 為 `true` 時,回傳**與輸入等長、保序**之逐筆陣列,元素沿用本規格逐筆結果之家族形狀:
287
+
288
+ | 情形 | `n` | `nInserted` | `ok` |
289
+ |---|---|---|---|
290
+ | 該筆已插入 | `1` | `1` | `1` |
291
+ | 該筆主鍵已存在而跳過(含同批重複之非首筆) | `1` | `0` | `1` |
292
+
293
+ - 元素之 `n` 恆為 `1`(見 T3),`ok` 恆為 `1`——`insert` 之任何錯誤皆屬整批性錯誤而 `reject`(T4 不變),故逐筆元素不出現 `ok: 0` 與 `err`。
294
+ - 不變式:陣列長度等於輸入筆數;`filter(v => v.nInserted === 1).length` 等於預設模式之 `nInserted`。
295
+ - 輸入無效時回傳 `[]`(對齊 `save`/`del` 之 T5 規定)。
296
+ - 事件依 T10:`change` 之 `res` 即本次實際回傳值。
297
+ - 選擇此形狀而非另創布林陣列欄位,係因「逐筆結果」於本規格已有既定形狀(`save`/`del` 之元素);沿用同一形狀,呼叫端得以同一段程式碼處理各函數之逐筆結果,且自單筆迴圈遷移至批次時判準(`nInserted === 1`)不變。
298
+
299
+ **其餘函數不提供本選項**:`save` 與 `del` 之回傳本即與輸入等長、保序之逐筆陣列,資訊已在(`rs[i].nInserted === 1` 即第 i 筆是否插入、`rs[i].nModified === 1` 即第 i 筆是否實際寫入),再加即為冗餘且無處可掛;`insertBulk` 成功時逐筆恆為已插入(零資訊量)、失敗時整批 `reject` 而無部分結果,兩種情況皆無資訊可回。
300
+
273
301
  ### insertBulk(data) → `Promise<{ n, nInserted, ok }>`
274
302
 
275
303
  批次插入,**全批視為一個單位**:全部插入成功,或一筆都不寫入。
@@ -386,6 +414,8 @@ select(find) → [ {...}, {...} ] 無符合為 []
386
414
  selectByPk(pk) → {...} | null
387
415
 
388
416
  insert(data) → { n, nInserted, ok }
417
+ insert(data, { returnList: true })
418
+ → [ { n, nInserted, ok }, ... ] 與輸入等長保序, 逐筆ok恆1
389
419
  insertBulk(data) → { n, nInserted, ok } 衝突即整批reject且不寫入任何一筆
390
420
  save(data, option) → [ { n, nInserted, nModified, ok }, ... ]
391
421
  del(data) → [ { n, nDeleted, ok }, ... ]
@@ -402,6 +432,7 @@ delAll(find) → { n, nDeleted, ok }
402
432
  | 要判斷什麼 | 看什麼 | 不要看什麼 |
403
433
  |---|---|---|
404
434
  | 這批有幾筆是新資料 | `insert` 之 `nInserted` | `n`(那是輸入筆數) |
435
+ | 這批裡**哪幾筆**是新資料 | `insert` 開啟 `returnList` 後逐筆之 `nInserted === 1` | 聚合之 `nInserted`(那只有數量沒有身分) |
405
436
  | 這批有沒有撞到既有主鍵 | `insertBulk` 是否 `reject` | `nInserted`(成功時恆等於 `n`,不帶額外資訊) |
406
437
  | 這筆是不是新資料 | `save` 之 `nInserted === 1` | `n`(命中即為 1,插入與更新皆是) |
407
438
  | 這筆內容有沒有實際寫入 | `save` 之 `nModified === 1` | `n` |
@@ -416,10 +447,10 @@ delAll(find) → { n, nDeleted, ok }
416
447
 
417
448
  新套件納入 `w-orm-*` 系列前,逐項確認:
418
449
 
419
- 1. 六個函數皆存在,單筆直讀函數依 T1 名為 `selectByPk`,主鍵欄位名已於函數註解與「各套件符合狀態」載明。
450
+ 1. 七個函數皆存在,單筆直讀函數依 T1 名為 `selectByPk`,主鍵欄位名已於函數註解與「各套件符合狀態」載明。
420
451
  2. `select` 恆回陣列且不含資料庫內部欄位,`selectByPk` 未命中回 `null`。
421
- 3. 每個函數之計數欄位恆出現,無對應行為時填 `0`;`err` 僅隨 `ok: 0` 出現。
422
- 4. `n` 依 T3 定義,四個函數各自的基準寫進函數註解。
452
+ 3. 每個函數之計數欄位恆出現,無對應行為時填 `0`;`err` 僅隨 `ok: 0` 出現。以 option 靜態切換回傳形式者(如 `insert` 之 `returnList`),同一取值下形狀恆定。
453
+ 4. `n` 依 T3 定義,五個函數各自的基準寫進函數註解。
423
454
  5. `insert` 與 `save` 符合 T7 原子性要求;若倚賴某項設定達成,該設定預設開啟,且 README 載明升級前提。採「條件寫入配合衝突偵測與重試」達成者,每次寫入皆為單一條件式原子語句、衝突可被偵測且重試能收斂,讀取結果未用於決定寫入內容,並已於「各套件符合狀態」載明後端之限制、衝突之偵測方式與重試上限。
424
455
  6. 成功路徑 `ok` 恆為 `1`,不由驅動層旗標推導;`ok: 0` 必附 `err`;單筆失敗不中斷整批。
425
456
  7. `save` 之「內容相同」採**合併後比對**,基準寫進註解。
@@ -429,7 +460,7 @@ delAll(find) → { n, nDeleted, ok }
429
460
  11. README 依 T8 宣告併發保證範圍,無法保證者載明後果、實測依據與迴避方式。
430
461
  12. 依 T10 發出 `change` 與 `error` 事件,參數形狀為 `(mode, data, res)` 與 `(mode, data, err)`;所採用之 `EventEmitter` 無「`'error'` 於無監聽者時拋出」之語義;每一處 `emit` 皆以 try/catch 包覆;正常結果不發出 `error`;事件所送出之資訊皆另有正規管道,移除全部事件後呼叫端仍能取得完整資訊。
431
462
  13. `insertBulk` 語義為「衝突即整批 reject 且不寫入任何一筆」而非 `insert` 之加速版,未以別名或轉呼叫實作;寫入於該後端會被拆為多次送出者已以交易包覆並驗證回滾,無交易可用而採補償動作者已載明其限制;實作方式與是否具效能優勢已於「各套件符合狀態」載明。
432
- 14. 測試須涵蓋:同批重複主鍵、主鍵不存在、合併後內容相同、只給部份欄位且值相同、`autoInsert` 兩種取值、`autoGenPk` 兩種取值(含 `false` 且未帶主鍵須 `reject`)、單筆失敗、未帶有效主鍵、`delAll` 帶條件且僅部份命中,以及**同一操作於有註冊與未註冊 `error` 監聽兩種情況下回傳值完全相同**。提供 `insertBulk` 者另須涵蓋:無衝突時 `nInserted` 等於 `n`、撞既有主鍵時整批 `reject`、同批重複主鍵時整批 `reject`,以及**失敗後資料表無任何新增**。
463
+ 14. 測試須涵蓋:同批重複主鍵、主鍵不存在、合併後內容相同、只給部份欄位且值相同、`autoInsert` 兩種取值、`autoGenPk` 兩種取值(含 `false` 且未帶主鍵須 `reject`)、單筆失敗、未帶有效主鍵、`delAll` 帶條件且僅部份命中,以及**同一操作於有註冊與未註冊 `error` 監聽兩種情況下回傳值完全相同**。提供 `insertBulk` 者另須涵蓋:無衝突時 `nInserted` 等於 `n`、撞既有主鍵時整批 `reject`、同批重複主鍵時整批 `reject`,以及**失敗後資料表無任何新增**。`insert` 之 `returnList` 另須涵蓋:兩種取值下之形狀、開啟時與輸入等長保序且對位正確、同批重複主鍵僅首筆 `nInserted` 為 `1`、`filter` 計數等於聚合模式之 `nInserted`、輸入無效回 `[]`、逐筆元素鍵集合恰為 `{n, nInserted, ok}`。
433
464
 
434
465
  ---
435
466
 
@@ -439,9 +470,9 @@ delAll(find) → { n, nDeleted, ok }
439
470
 
440
471
  主鍵欄位為 `id`,為無業務語義之識別碼。主鍵欄位目前**固定為 `id`,尚未支援由呼叫端指定**。
441
472
 
442
- 已符合 T1–T10 與六函數全部規格,無待處理項目。
473
+ 已符合 T1–T10 與七函數全部規格,無待處理項目。
443
474
 
444
- T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),本即無「`'error'` 於無監聽者時拋出」之語義,符合 T10.1 第 2 條且無須改動。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於六函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。
475
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),本即無「`'error'` 於無監聽者時拋出」之語義,符合 T10.1 第 2 條且無須改動。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。
445
476
 
446
477
  `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值;為 `false` 時 `insert` 與 `save` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`del` 不受此設定影響。
447
478
 
@@ -458,15 +489,25 @@ T8 已完成:README 已宣告單一行程內保證成立、跨行程因 `lmdb-
458
489
 
459
490
  **效能與 `insert` 無顯著差異**(實測 N=20000:`insert` 650ms、`childTransaction` 640ms、`transactionSync` 645ms),因本套件之 `insert` 本即以 `Promise.all` 一次送出全部條件寫入而非逐筆 await。提供本函數係為與其他套件維持同一組函數,令呼叫端得於各套件間替換而不須改寫呼叫。
460
491
 
492
+ **`insert` 之 `option.returnList`:已實作。** 逐筆判定即 `Promise.all` 各 `ifNoExists` 之回傳值(與輸入等長、保序),開啟時僅改包裝為逐筆結果陣列而不摺成計數,零推導成本。同批重複主鍵者僅首筆 `nInserted` 為 `1`,與聚合模式之計數一致。
493
+
494
+ **close() 後之快速失敗:已實作**(T4「實例已關閉」條)。七函數入口共用 `procClosed` 守門,`close()` 後再操作一律於入口立即以整批性錯誤 `reject`(訊息明示 closed,並依 T10.3 發出 `error` 事件),不與「查無資料」同形;`waitOpen` 另有終態前置判斷作為縱深防禦(攔操作進行中被 close 之競態),並移除輪詢中之逐秒 stdout 輸出、等待參數收斂為 50×200ms(waitFun 預設 200×1s)。修正前之實測症狀:`select`/`selectByPk`/`delAll` 於 close 後空轉約 200 秒方 reject;`del` 靜默回「主鍵未命中」之正常結果(`{n:0, nDeleted:0, ok:1}`,fail-open);`save` 誤降為逐筆 `ok: 0` 之 resolve;`insertBulk` 之 `childTransaction` 於已關閉 env 上自 `setImmediate` 拋出未捕捉例外而使**行程崩潰**——皆由入口守門一併攔下。
495
+
461
496
  ### w-orm-mongodb
462
497
 
463
498
  主鍵欄位為 `id`,為無業務語義之識別碼。主鍵欄位目前**固定為 `id`,尚未支援由呼叫端指定**,已於類別註解、`selectByPk` 註解與 README 載明。
464
499
 
465
- 已符合 T1–T10 與六函數全部規格,無待處理項目。
500
+ 已符合 T1–T10 與七函數之全部規格,無待處理項目。
466
501
 
467
- T10 之實作:`EventEmitter``wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。全部事件收斂於共用之 `emitChange(mode, data, res)` `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` `insert` 之事件;`error` 於六函數與四個 GridFS 專屬函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。內部查找函數 `_findGfs` 不自行發出 `error`,其 reject `delGfs` `delAllGfs` catch 接住並於該處發出,以免重複。`selectByPkGfs` 之「查無檔案」於 catch 內以 `code` 為 `ENOENT` 判定為正常結果而不設 `isErr`,故不會誤發 `error`。
502
+ `insert` 之 `option.returnList` 已實作。逐筆判定不須額外查詢:`insertMany({ ordered: false })` `writeErrors` `index` 即為未插入者於輸入內之位置,故由同一往返即可得——有索引者該筆 `nInserted` `0`,其餘為 `1`;`n` `ok` 恆為 `1`。回傳形式之切換為靜態,僅由 `option.returnList` 之取值決定,不因數據內容或執行結果而變。測試涵蓋兩種取值之形狀、等長保序與對位正確、同批重複主鍵僅首筆為 `1`、`filter` 計數等於聚合模式之 `nInserted`、輸入無效回 `[]`、逐筆元素鍵集合恰為 `{n, nInserted, ok}`、`change` 事件之 `res` 即實際回傳值,以及 `autoGenPk` 為 `false` 時仍為整批 `reject` 而不因本選項降為逐筆。
468
503
 
469
- `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值——即 UUIDv7,符合 T6「如 UUID 或單調序列」,且其時間有序之特性令寫入主鍵唯一索引時之索引局部性優於純隨機字串;為 `false` `insert`、`save` 與 `insertGfs` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查以共用之 `procPk` 於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`autoGenPk` 僅為建構層設定,`insert` 與 `save` 之 `option` 未提供覆寫。`del` 不受此設定影響。
504
+ **T4「實例已關閉」條不適用本套件**:本套件不提供 `close()`,每次操作各自建立 MongoClient 並於 `finally` 關閉,不保有跨呼叫之行程內狀態,故無「實例已關閉」之狀態存在,亦無該條所欲防範之空轉逾時與靜默 fail-open。
505
+
506
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七函數與四個 GridFS 專屬函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。內部查找函數 `_findGfs` 不自行發出 `error`,其 reject 由 `delGfs` 與 `delAllGfs` 之 catch 接住並於該處發出,以免重複。`selectByPkGfs` 之「查無檔案」於 catch 內以 `code` 為 `ENOENT` 判定為正常結果而不設 `isErr`,故不會誤發 `error`。
507
+
508
+ `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值——即 UUIDv7,符合 T6「如 UUID 或單調序列」,且其時間有序之特性令寫入主鍵唯一索引時之索引局部性優於純隨機字串;為 `false` 時 `insert`、`insertBulk`、`save` 與 `insertGfs` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查以共用之 `procPk` 於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`autoGenPk` 僅為建構層設定,`insert`、`insertBulk` 與 `save` 之 `option` 未提供覆寫。`del` 不受此設定影響。
509
+
510
+ `save` 之重試上限為 3 次:其主體為單一 `updateOne` 配合 `upsert`,MongoDB 於單一語句內即可回報係插入或更新(`upsertedCount` 與 `matchedCount`),故不屬 T7 所稱「條件寫入配合衝突偵測與重試」之形式;該重試僅為處理「併發 upsert 於唯一索引上可能拋重複鍵錯誤」之既知競態,重試時該主鍵已存在故必走更新路徑而收斂。
470
511
 
471
512
  `save` 之「內容相同」判定即本規格之基準來源:由 MongoDB 於伺服器端將 `$set` 之待寫入物件合併進現值後與現值比對,未寫入即回 `modifiedCount` 為 `0`,比對與寫入於同一原子操作內完成,故不須預讀。
472
513
 
@@ -481,43 +522,68 @@ GridFS 之寫入係先寫 chunks 再寫 files 文件,故違反唯一索引時
481
522
  `save` 無 GridFS 對應函數:GridFS 無法於單一原子操作內取代既有內容,提供 `saveGfs` 將違反 T7,故不提供,更新以 `delGfs` 後再 `insertGfs` 完成。
482
523
 
483
524
 
484
- **`insertBulk`:待實作。** 全有全無之達成方式依部署而異:具 replica set 者以交易包覆 `insertMany({ ordered: true })`;standalone 無交易可用,須以補償動作達成——`insertMany({ ordered: false })` 後若 `insertedCount` 小於 `n`,由 `writeErrors` 取得衝突之索引,刪除本次已寫入之筆數後再 `reject`,並須於 README 載明「行程於補償途中中止則可能殘留」之限制。**不得以別名指向 `insert`**:如此則主鍵衝突時不會 `reject` 而是靜默跳過,呼叫端之錯誤前提於本套件上永遠不會浮現。本函數於本後端不會較 `insert` 快(`insert` 本即一次往返且計數精確),仍須提供以維持跨套件之可替換性。
525
+ **`insertBulk`:已實作。** 全有全無之達成方式依部署而異,由套件於執行期以 `hello` 回應判定拓樸(`setName` 存在或 `msg` `isdbgrid` 即表交易可用),每個實例判定一次並快取:
526
+
527
+ - **具 replica set 或分片叢集者以交易達成**——`session.withTransaction` 包覆 `insertMany({ ordered: true })`,任一筆衝突即整批回滾,無須補償動作。
528
+ - **standalone 無交易可用,以補償動作達成**——`insertMany({ ordered: false })` 失敗後刪除本次已寫入者再 `reject`。刪除之依據為**本次由驅動於送出前在用戶端所產生之 `_id`**,而非由 `writeErrors` 之索引反推輸入之主鍵值:後者於錯誤不帶 `writeErrors` 時(如網路中斷)會把全部輸入主鍵當成本次寫入而刪除,其中已存在者屬呼叫前既有資料,將造成資料損毀。已實測確認衝突筆所獲配之 `_id` 與庫內既有同主鍵者之 `_id` 不同,故按 `_id` 刪除不會誤及既有資料;刪除未寫入者為無操作,故不須精確得知哪幾筆已寫入。**限制**:行程若於補償途中中止則已寫入之部份可能殘留,已於 README 明白載明。
529
+
530
+ **未以別名或轉呼叫 `insert` 實作**:如此則主鍵衝突時不會 `reject` 而是靜默跳過,呼叫端之錯誤前提於本套件上永遠不會浮現。
531
+
532
+ **本函數於本後端不會較 `insert` 快**(`insert` 本即一次往返且計數精確),提供之理由為語義與跨套件之可替換性。
533
+
534
+ 兩條路徑皆已測試:standalone 之補償路徑見 `test/api-basic.test.mjs` 之 `insertBulk`,交易路徑見 `test/api-insertbulk-rs.test.mjs`。後者另起 `--replSet rs0` 之容器,並先斷言 `hello.setName` 存在以證明確走交易路徑,涵蓋撞既有主鍵、同批重複主鍵、200 筆批次末筆衝突之回滾,皆斷言失敗後筆數增量為 0 且既有數據未被改動。該容器不開啟認證,因 `--replSet` 配合 root 帳號須另備 keyFile 以供成員間內部認證,於測試無必要;連線採 `directConnection=true`,因容器內之成員位址為 `127.0.0.1:27017`,由宿主機依該位址無法連線,不可令驅動走成員探索。
535
+
536
+ **GridFS 不提供 `insertBulkGfs`**:依 §7 專屬函數不在本規格範圍內,§9 亦僅要求「若與六函數概念對應則參數形狀比照」而未要求補齊所有概念。GridFS 每筆寫入為 chunks 與 files 兩階段,全有全無所須清理之對象較一般資料表複雜,且無對應之使用場景。
485
537
 
486
538
  ### w-orm-postgresql
487
539
 
488
- 主鍵欄位由建構時之 `opt.pk` 指定,預設為 `time`,**已支援由呼叫端指定**,`select` 以外之五函數皆以該欄位認定主鍵,已於類別註解、`selectByPk` 註解與 README 載明。
540
+ 主鍵欄位由建構時之 `opt.pk` 指定,預設為 `time`,**已支援由呼叫端指定**,`select` 以外之六函數皆以該欄位認定主鍵,已於類別註解與 `selectByPk` 註解載明。
489
541
 
490
- 已符合 T1–T10 與六函數全部規格,無待處理項目。
542
+ 已符合 T1–T10 與七函數之全部規格,無待處理項目。
491
543
 
492
- T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於六函數與專屬函數 `createTable` 皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。`selectByPk` 之「主鍵未命中」與「主鍵值型別與欄位不符」皆不設 `isErr` 而回傳 `null`,故不會誤發 `error`;`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中亦同。
544
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七個函數與專屬函數 `createTable` 皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。`selectByPk` 之「主鍵未命中」與「主鍵值型別與欄位不符」皆不設 `isErr` 而回傳 `null`,故不會誤發 `error`;`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中亦同。
493
545
 
494
546
  **不提供`autoGenPk`**,依 T6 之例外辦理,且兩種情形皆成立:
495
547
 
496
548
  1. 預設主鍵 `time` 承載觀測時間之業務意義。此類欄位若自動補值,將使「呼叫端漏給主鍵」靜默變成「以當下之值多寫入一筆」,且該筆無從與正常資料區辨。
497
549
  2. **主鍵欄位可由呼叫端指定,其型別則由資料表 schema 決定,套件並不知情**,故無從產生型別相容且符合 T6「須具足夠唯一性」之值:主鍵為 `TIMESTAMPTZ` 時可產生者僅有當下時間,同一毫秒內併發即碰撞,違反 T6「不得採用可預期碰撞之來源」;為 `TEXT` 時方適用隨機字串;為 `INTEGER` 時須倚賴資料庫端之 sequence。三者所需之產生策略互斥,而套件於寫入前無從得知係何者。
498
550
 
499
- 故縱使呼叫端將主鍵指定為無業務語義之欄位,本套件仍不提供補值,主鍵一律由呼叫端自備。`insert` 與 `save` 於輸入未帶有效主鍵值時,以 `Promise.reject` 拋出整批性錯誤;主鍵檢查於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`del` 不受此設定影響,未帶有效主鍵值者仍依 T6 回該筆 `ok: 0` + `err`。
551
+ 故縱使呼叫端將主鍵指定為無業務語義之欄位,本套件仍不提供補值,主鍵一律由呼叫端自備。`insert`、`insertBulk` 與 `save` 於輸入未帶有效主鍵值時,以 `Promise.reject` 拋出整批性錯誤;主鍵檢查於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`del` 不受此設定影響,未帶有效主鍵值者仍依 T6 回該筆 `ok: 0` + `err`。
500
552
 
501
- 主鍵值之有效性僅要求有給值而不限定型別,理由同上——主鍵欄位得由呼叫端指定而其型別隨欄位而異。型別與欄位不符者由 PostgreSQL 回報 `22P02` 或 `22007`,各函數依其規格分別處置:`selectByPk` 視為主鍵值無效而回傳 `null`,`insert` 與 `save` 為整批性錯誤而 `reject`,`del` 為該筆失敗而回 `ok: 0` + `err`。
553
+ 主鍵值之有效性僅要求有給值而不限定型別,理由同上——主鍵欄位得由呼叫端指定而其型別隨欄位而異。型別與欄位不符者由 PostgreSQL 回報 `22P02` 或 `22007`,各函數依其規格分別處置:`selectByPk` 視為主鍵值無效而回傳 `null`,`insert`、`insertBulk` 與 `save` 為整批性錯誤而 `reject`,`del` 為該筆失敗而回 `ok: 0` + `err`。
502
554
 
503
555
  `save` 之「內容相同」判定採合併後比對:以待寫入物件之非主鍵欄位淺層覆蓋現值後與現值比對。合併取淺層而非深層,係為與後端以 `EXCLUDED` 整欄取代之寫入行為一致——判定基準與實際寫入行為若不一致,`nModified` 即無法忠實反映是否真的寫入。快速路徑之預讀僅用於判斷是否略過寫入,寫入內容一律由原子語句自身決定。
504
556
 
505
- T7 之原子性以 `ON CONFLICT` 達成:`insert` 為單一 `INSERT ... ON CONFLICT (主鍵) DO NOTHING`,取 `rowCount` 為 `nInserted`;`save` 於 `autoInsert` 開啟時為單一 `INSERT ... ON CONFLICT (主鍵) DO UPDATE ... RETURNING (xmax = 0)`,以 `xmax` 區辨本語句插入與衝突後更新,關閉時為單一 `UPDATE ... WHERE 主鍵` 以免無中生有。其前提為主鍵具唯一約束,`createTable` 一律以 `PRIMARY KEY` 建立且無關閉選項;README Upgrading 已載明資料表於他處建立而缺該約束時之補建步驟,以及補建前須先清除重複主鍵值。
557
+ T7 之原子性以 `ON CONFLICT` 達成:`insert` `INSERT ... ON CONFLICT (主鍵) DO NOTHING`,取 `rowCount` 為 `nInserted`,筆數超出綁定參數上限者分批送出(見下方 `insertBulk` 之說明),因各筆之檢查與寫入本即逐筆獨立,分批不影響本項要求;`save` 於 `autoInsert` 開啟時為單一 `INSERT ... ON CONFLICT (主鍵) DO UPDATE ... RETURNING (xmax = 0)`,以 `xmax` 區辨本語句插入與衝突後更新,關閉時為單一 `UPDATE ... WHERE 主鍵` 以免無中生有。其前提為主鍵具唯一約束,`createTable` 一律以 `PRIMARY KEY` 建立且無關閉選項;資料表若於他處建立而缺該約束,`insert` `save` 將以 `there is no unique or exclusion constraint matching the ON CONFLICT specification` 拒絕,須先清除重複主鍵值後補建約束。
506
558
 
507
559
  T8 無須宣告:單一行程內併發與跨行程併發皆成立,故依 T8 未於 README 宣告。原子性全由 PostgreSQL 伺服器端提供,**寫入路徑不保有行程內狀態**且每次呼叫各自開啟連線,故兩種範圍於後端為同一情形。已測試依據含 2 個獨立行程對相同 20 個主鍵併發 `insert`、`nInserted` 總和為 20 且資料表僅 20 筆,以及 2 個行程對同一主鍵各寫入 5 個不同欄位、10 欄位全數保留。
508
560
 
509
561
  惟 `opt.useCache` 開啟時,`select` 與 `selectByPk` 之快取為行程內狀態:本行程之寫入會重設快取,他行程之寫入則不會,故跨行程下得讀到過期數據。此僅影響讀取之新鮮度,不影響上述寫入之原子性——快取不參與寫入路徑,`insert`、`save`、`del` 之判定與寫入一律由資料庫端完成。該選項預設為關閉,且已於類別註解載明適用於單程序操作。
510
562
 
511
- 本套件另有專屬函數 `createTable`,依 §7 不在本規格範圍內,其 `pk` 參數未給時採 `opt.pk`;因 T7 之原子性倚賴主鍵之唯一約束,該參數若給予與 `opt.pk` 不同之欄位,將建出其餘函數無法正確操作之資料表,已於函數註解與 README 載明。
563
+ 本套件另有專屬函數 `createTable`,依 §7 不在本規格範圍內,其 `pk` 參數未給時採 `opt.pk`;因 T7 之原子性倚賴主鍵之唯一約束,該參數若給予與 `opt.pk` 不同之欄位,將建出其餘函數無法正確操作之資料表,已於函數註解載明。
564
+
565
+
566
+ **`insertBulk`:已實作。** 全有全無以「單一多值 `INSERT`(不加 `ON CONFLICT`)配合交易包覆」達成:不加 `ON CONFLICT` 時,任一筆撞主鍵之唯一約束即整句失敗且不寫入任何一筆,同批含重複主鍵者亦於此被偵測為衝突,無須另行比對。未以 `insert` 之別名或轉呼叫實作,兩者之語句與衝突政策各自獨立。
567
+
568
+ PostgreSQL 之協定以 int16 記綁定參數個數,上限為 65535,故單一語句可送之筆數受欄位數所限(筆數 × 欄位數 ≤ 65535),超出者依 `floor(65535 / 欄位數)` 分批送出。分批時單語句之原子性已不足以維持「失敗即不留下部份寫入」,故各批一律以 `BEGIN`/`COMMIT` 包覆,任一批失敗即 `ROLLBACK`;不因批數而異,令單批與分批之保證來源一致。
569
+
570
+ **分批與交易包覆為 `insert` 與 `insertBulk` 共用**,收斂於內部之 `insertBatches`;衝突政策由參數區分而語句各自獨立——`insert` 添加 `ON CONFLICT DO NOTHING`,`insertBulk` 不添加。故 `insertBulk` 非 `insert` 之別名或轉呼叫,兩者之語義各自成立:已測試 21 欄之資料表送 3200 筆(分 2 批)而其中既有 1 筆主鍵者,`insert` 回 `nInserted` 為 3199 且資料表為 3200 筆,`insertBulk` 則整批 `reject` 且資料表之筆數增量為 0。
571
+
572
+ **效能優於 `insert`**,且筆數愈多差距愈大。同一資料表無衝突插入之實測(Windows 11、PostgreSQL 17.11、Node v24.19.0,各 3 輪取平均):1000 筆 41ms→40ms、5000 筆 100ms→57ms、20000 筆 350ms→148ms。原因為 `insert` 之 `ON CONFLICT DO NOTHING` 須對每列進行推測性插入,`insertBulk` 無此開銷;筆數少時該開銷不顯著,故兩者相當。
512
573
 
574
+ `insert` 於採用共用之分批機制前未分批,3 欄之資料表送 25000 筆即以 `bind message has 9464 parameter formats but 0 parameters` 拒絕且 0 筆寫入;改用後兩函數於同一輸入下皆分批完成 25000 筆。
513
575
 
514
- **`insertBulk`:待實作。** 單一多值 `INSERT`(不加 `ON CONFLICT`)天然即為全有全無——任一筆撞主鍵約束則整句失敗且不寫入任何一筆,正合本函數語義。惟參數數量超出上限而須分批送出時,須以交易包覆。效能是否優於 `insert` 待實測。
576
+ **`insert` `option.returnList`:已實作。** 於 `INSERT ... ON CONFLICT DO NOTHING` 附加 `RETURNING 主鍵`——該子句僅回傳實際插入之列,故以其主鍵值映回輸入序即得逐筆判定;分批送出時逐批收集再串接。同批含重複主鍵者 `RETURNING` 僅回一次,故映回時以首次出現者之 `nInserted` 為 `1`,其餘為 `0`。`insertBulk` 不附此子句,因其成功時逐筆恆為已插入而無資訊量。
577
+
578
+ 映回時之比對**依主鍵欄位之實際型別分流**:PostgreSQL 所回傳之型別未必與輸入相同,主鍵為 `TIMESTAMPTZ` 時輸入為字串而回傳為 `Date` 物件,直接比對必然落空而使每一筆皆誤判為跳過,且該錯誤於僅驗聚合計數之測試下不會顯現。故先以 `rows[0][主鍵] instanceof Date` 判定欄位型別,再令兩側共用同一正規化函數。
579
+
580
+ 不採「一律嘗試解析為日期」之通用寫法:主鍵為文字欄位而值形如 `2025` 與 `2025-01-01` 者,將正規化為同一時刻而碰撞,令已存在者誤判為新插入。已就此加測——文字主鍵之資料表已存在 `2025` 時,送 `2025` 與 `2025-01-01` 得 `[0, 1]`。
515
581
 
516
582
  ### w-orm-reladb
517
583
 
518
- 主鍵欄位由建構時之 `opt.pk` 指定,預設為 `id`,**已支援由呼叫端指定**,`select` 以外之五函數皆以該欄位認定主鍵,已於 `selectByPk` 註解與 README 載明。本套件經 sequelize 操作 mssql、sqlite、mysql、mariadb、postgres 五種後端。
584
+ 主鍵欄位由建構時之 `opt.pk` 指定,預設為 `id`,**已支援由呼叫端指定**,`select` 以外之六函數皆以該欄位認定主鍵,已於 `selectByPk` 註解與 README 載明。本套件經 sequelize 操作 mssql、sqlite、mysql、mariadb、postgres 五種後端。
519
585
 
520
- 已符合 T1–T10 與六函數全部規格,無待處理項目。
586
+ 已符合 T1–T10 與七函數之全部規格,含 `insert` 之 `option.returnList`,無待處理項目。
521
587
 
522
588
  `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值——為 UUIDv7 格式之 36 碼字串,具單調遞增性,主鍵接近順序遞增可減少 B-tree 索引之頁面分裂;為 `false` 時 `insert` 與 `save` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查以共用之 `procPk` 於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`autoGenPk` 僅為建構層設定,`insert` 與 `save` 之 `option` 未提供覆寫。`del` 不受此設定影響。
523
589
 
@@ -537,11 +603,165 @@ T8 已完成:README 已宣告兩範圍之適用情形。**跨行程併發成
537
603
 
538
604
  註:此限制非受後端或其驅動層所限,而係本套件之連線管理所致——w-orm-mongodb 與 w-orm-postgresql 每次操作各自建立連線且不保有行程內狀態,故兩種範圍於後端為同一情形而無此區分。本套件因提供 `option.instance` 與 `option.transaction` 之共用連線擴充而保有行程內狀態,改為每次呼叫各自持有連線須一併調整該擴充,屬架構層級改動,暫以佇列預設開啟迴避。
539
605
 
540
- T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。改用前曾實測其後果:呼叫端未註冊 `'error'` 監聽時,`save` 之逐筆失敗會被升級為整批 `reject`,違反 T4 之「單筆失敗不中斷整批」。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於六函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中、`selectByPk` 查無數據皆為正常結果而不設 `isErr`,故不會誤發 `error`。
606
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。改用前曾實測其後果:呼叫端未註冊 `'error'` 監聽時,`save` 之逐筆失敗會被升級為整批 `reject`,違反 T4 之「單筆失敗不中斷整批」。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七個函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中、`selectByPk` 查無數據皆為正常結果而不設 `isErr`,故不會誤發 `error`。
541
607
 
542
- 本套件之六函數另有專屬之 `option.instance` 與 `option.transaction` 兩參數,供呼叫端共用連線實例與交易,未給時各函數自行初始化與關閉;此為向後相容之選配擴充,未給予時行為與規格所定完全相同,故不與六函數之語義衝突。另有專屬函數 `createStorage`、`genModelsByDB`、`genModelsByTabs`、`init`、`genTransaction`,依 §7 不在本規格範圍內,且皆不與六函數之概念對應。
608
+ 本套件之七函數另有專屬之 `option.instance` 與 `option.transaction` 兩參數,供呼叫端共用連線實例與交易,未給時各函數自行初始化與關閉;此為向後相容之選配擴充,未給予時行為與規格所定完全相同,故不與規格所定函數之語義衝突。另有專屬函數 `createStorage`、`genModelsByDB`、`genModelsByTabs`、`init`、`genTransaction`,依 §7 不在本規格範圍內,且皆不與規格所定函數之概念對應。
543
609
 
544
610
  建構時 `opt.url` 解析失敗一律 `throw`:其屬呼叫端未履行契約,且解析失敗後各函數皆無從運作;建構為同步而無從以 `Promise.reject` 回報,拋出即為 `reject` 之同步對應。原先僅 `console.log` 而回傳未綁定各函數之 `EventEmitter`,錯誤不經任何管道抵達呼叫端,呼叫端僅得到 `w.select is not a function` 之無關訊息,與本規格「錯誤不得靜默」之通則相違。
545
611
 
546
612
 
547
- **`insertBulk`:待實作,且效能效益最高。** 其後端之批次語句無從回報「本批有幾筆真的插入」,致 `insert` 只能逐筆寫入;改用批次語句後實測快上數量級。實作須以交易包覆——實測 mssql 因參數數量上限而由驅動層自動拆為多語句,1000 筆之批次於最後一筆撞主鍵時已有 946 筆落盤,未包交易即違反全有全無;包覆後兩後端於失敗時皆為 0 筆新增。
613
+ **`insertBulk`:已實作。** 全有全無以「單次 `bulkCreate`(不加任何跳過選項)配合交易包覆」達成:不加跳過選項時,任一筆撞主鍵之唯一約束即整句失敗,同批含重複主鍵者亦於此被偵測為衝突,無須另行比對。未以 `insert` 之別名或轉呼叫實作——`insert` 為逐筆 `create` 並攔截 `UniqueConstraintError` 以跳過既有主鍵,`insertBulk` 為單次 `bulkCreate` 且不攔截,兩者之語句與衝突政策各自獨立。
614
+
615
+ **交易包覆為必要而非優化。** 實測 mssql 因綁定參數數量上限(2100)而由驅動層自動將批次拆為多語句送出,1000 筆之批次於最後一筆撞主鍵時已有 **946 筆落盤**;sqlite 之批次為單一語句故天然原子。包覆後兩後端於失敗時皆為 0 筆新增,已以測試斷言。
616
+
617
+ **呼叫端已給 `option.transaction` 時改以巢狀交易(SAVEPOINT)包覆**,令回滾範圍限於本次呼叫。若逕自於呼叫端之交易內分批寫入而中途失敗,前段將留在該交易內未回滾,全有全無即不成立;亦不得回滾呼叫端之交易,那會連帶撤銷其先前之寫入。已實測 sqlite 與 mssql 之 SAVEPOINT 回滾範圍皆正確——外層交易先前之寫入保留、內層失敗之批次全數不存在,並以測試斷言。
618
+
619
+ **效能優於 `insert`,且筆數愈多差距愈大**(Windows 11、Node 24、sequelize 6、MSSQL 2022、SQLite 經 sqlite3 綁定):
620
+
621
+ | 筆數 | sqlite `insert` → `insertBulk` | mssql `insert` → `insertBulk` |
622
+ |---|---|---|
623
+ | 1000 | 5549 ms → 25 ms(226x) | 6802 ms → 190 ms(36x) |
624
+ | 5000 | 29825 ms → 50 ms(592x) | 44116 ms → 489 ms(90x) |
625
+
626
+ 差距之來源為 `insert` 須逐筆寫入方能回報精確之 `nInserted`(見上方 T7 之說明——本後端之批次語句無從回報「本批有幾筆真的插入」),`insertBulk` 因採全有全無而無此需求。
627
+
628
+ `opt.useEncryption` 開啟且後端為 sqlite 時不自開交易,因 `@journeyapps/sqlcipher` 不支援交易;該情形下全有全無倚賴 sqlite 單一批次語句之原子性。
629
+
630
+ **`insert` 之 `option.returnList`:已實作。** 逐筆判定即 `insert` 逐筆 `create` 之成敗(撞既有主鍵者以 `UniqueConstraintError` 辨識為跳過),與輸入等長、保序,開啟時僅改包裝為逐筆結果陣列而不摺成計數,零推導成本。同批重複主鍵者僅首筆 `nInserted` 為 `1`(逐筆循序送出,其後同鍵者撞唯一約束而跳過),與聚合模式之計數一致。回傳形式之切換為靜態,僅由該選項之取值決定。`insertBulk` 不提供本選項,因其成功時逐筆恆為已插入而無資訊量。測試涵蓋兩種取值之形狀、等長保序與對位正確、同批重複主鍵僅首筆為 `1`、`filter` 計數等於聚合模式之 `nInserted`、輸入無效回 `[]`、逐筆元素鍵集合恰為 `{n, nInserted, ok}`、`change` 事件之 `res` 即實際回傳值,以及 `autoGenPk` 為 `false` 時仍為整批 `reject` 而不因本選項降為逐筆。
631
+
632
+ **close() 後之快速失敗:已實作**(T4「實例已關閉」條)。本套件提供 `init()` 取得實例、`instance.close()` 關閉,並以 `option.instance` 供呼叫端共用,故「實例已關閉」之狀態確實存在,與 w-orm-mongodb、w-orm-mdb 之「不保有行程內狀態故不適用」不同。
633
+
634
+ 七函數入口共用 `procClosed(instance)` 守門:**判定依據為套件自身之閉包變數 `sequelize` 是否為 `null`**——`closeSequelize()` 於關閉後將其設為 `null`,故為精確訊號,不須倚賴驅動層之錯誤訊息比對。此點有其必要:sequelize 之 `ConnectionManager.close()` 係將 `getConnection` 換成拋出**原生 `Error`** 之函數(訊息為 `ConnectionManager.getConnection was called after the connection manager was closed!`),該錯誤並非 `ConnectionError` 之實例,無從以錯誤類別辨識。
635
+
636
+ `selectByPk` 之守門**置於「主鍵值無效回 `null`」之判定之前**,否則兩者同形而使「已關閉」被誤讀為「查無資料」。
637
+
638
+ 另於 `save` 與 `del` 之逐筆錯誤處置加入 `isBatchLevelError` 判定:連線層錯誤(`Sequelize.ConnectionError` 家族,及訊息含 `connection manager was closed` 或 `SQLITE_MISUSE` 之原生錯誤)影響後續所有筆數,依 T4 之判別原則往外拋為整批性錯誤,而不降為該筆 `ok: 0`。
639
+
640
+ 修正前之實測症狀(close 後再以該實例操作):`select`、`selectByPk`、`insert`、`insertBulk`、`delAll` 已為 `reject` 且皆於 20ms 內完成而無空轉問題;惟 **`save` 與 `del` 將整批性錯誤降級為逐筆 `ok: 0` 而整批 `resolve`**——呼叫端若只看 Promise 是否 `reject` 即誤判整批成功,縱使逐筆檢查亦僅見「這幾筆失敗」而不知實例已死、後續每一筆皆不可能成功。修正後七函數於 close 後一律整批 `reject`,訊息明示 closed,並依 T10.3 發出 `error` 事件,已以測試斷言。
641
+
642
+ ### w-orm-mdb
643
+
644
+ 主鍵欄位由建構時之 `opt.pk` 指定,預設為 `id`,**已支援由呼叫端指定**,`select` 以外之六函數皆以該欄位認定主鍵,已於類別註解與 `selectByPk` 註解載明。本套件經自帶之 `connMDB.exe`(C# 源碼於 `srcCs/MdbBridge.cs`,以 Windows 內建 csc 編譯)操作 Windows 內建之 Jet 4.0 引擎,僅支援 `.mdb`(Jet4)不支援 `.accdb`。資料不經主控台管線:輸入寫暫存 JSON 檔,查詢結果由 exe 逐列串流寫 jsonl 檔。
645
+
646
+ 已符合 T1–T10 與七函數之全部規格,含 `insert` 之 `option.returnList`,無待處理項目。
647
+
648
+ **exe 為 one-shot:** 每次呼叫跑一次 exe,各自開啟與關閉連線,故本套件**不保有任何行程內狀態**。因此不提供 `option.transaction` 與 `genTransaction`——跨呼叫之交易於此架構下無從維持;交易僅用於**單次呼叫內部**(`insertBulk` 之全有全無、`delAll` 之分批刪除)。亦因無持久實例,T4 所稱「實例已關閉」之情形於本套件不存在。
649
+
650
+ `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值(UUIDv7 格式之 36 碼字串);為 `false` 時 `insert`、`insertBulk` 與 `save` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查以共用之 `procPk` 於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`autoGenPk` 僅為建構層設定,各寫入函數之 `option` 未提供覆寫。`del` 不受此設定影響。**不採 T6 之例外**,理由同 w-orm-reladb:本套件持有 sequelize 之 model 定義,得由 `rawAttributes[opt.pk].type.key` 讀得主鍵型別,故例外第 2 種情形不成立;`procPk` 於補值前檢查主鍵欄位確為字串類型,不符者以明確訊息拋出整批性錯誤。
651
+
652
+ **Jet 之字串比對不分大小寫,本套件據此統一各函數之命中判定。** 已實測:`WHERE [id]='ID-PETER'` 命中既存之 `id-peter`,且主鍵唯一約束亦視兩者為同一鍵而拒絕插入。`selectByPk`、`insert`、`save`、`del` 皆走 Jet 故天然一致;惟 `select` 之複雜條件係由 Jet 取回全部數據後於**記憶體 sqlite** 內過濾(因 Access 之 SQL 方言不支援 `$in`、`$nin`、`$regex` 等),而 sqlite 預設之 BINARY 定序**分**大小寫,若不處理即出現「`selectByPk('ID-X')` 回傳物件而 `select({id:'ID-X'})` 查無」之矛盾,與 `selectByPk` 規格所禁止者同型。故過濾表之字串欄位一律以 `CITEXT`(於 sqlite 即 `TEXT COLLATE NOCASE`)承載,令兩層一致。**限制**:sqlite 之 `NOCASE` 僅折疊 ASCII 之 `A-Z`,而 Jet 之定序另折疊部分非 ASCII 字元,故僅大小寫相異之非 ASCII 主鍵於兩層仍可能不一致,已於類別註解載明。
653
+
654
+ `save` 之「內容相同」判定採合併後比對:以待寫入物件之非主鍵欄位淺層覆蓋現值後與現值比對,並先以 model 之欄位名濾除非欄位之鍵。合併取淺層以與 Jet 整欄取代之寫入行為一致。**不以 UPDATE 之影響列數決定 `nModified`**:已實測 Jet 之影響列數為「符合 WHERE 之列數」而非「內容真的有變之列數」(將欄位設為與現值相同之值仍回報影響 1 列),若逕取之,`nModified` 將無法忠實反映是否真的寫入。快速路徑之預讀僅用於判斷是否略過寫入,寫入內容一律由該次寫入語句自身決定。
655
+
656
+ T7 之原子性採**條件寫入配合衝突偵測與重試**。所依據之後端限制為:**Jet 無 upsert 語句,亦無從於單一語句內回報本次係插入或更新**——其 SQL 方言無 `ON CONFLICT`/`MERGE ... OUTPUT $action` 之對應物,`ExecuteNonQuery` 僅回影響列數而不帶路徑資訊。若堅持以單一語句達成 T7,即無從提供 `insert` 與 `save` 所要求之 `nInserted`。
657
+
658
+ 其實作為:`insert` 逐筆送單一 `INSERT`,由 Jet 之主鍵唯一約束原子完成「檢查主鍵不存在」與「寫入」,撞既有主鍵者視為已存在而跳過;`save` 先預讀以判斷內容是否相同,存在者發單一 `UPDATE ... WHERE 主鍵`、不存在且開啟 `autoInsert` 者發單一 `INSERT`,兩者皆為條件式原子語句而非由預讀值決定成敗。**衝突之偵測方式**為:插入撞既有鍵以 Jet 錯誤編號 **3022** 辨識,更新未命中任何列以影響列數為 `0` 辨識。該錯誤編號取自 `OleDbError.SQLState`(Jet provider 於此欄位承載有文件之引擎錯誤編號),由 exe 以 `errorCode` 回傳;**不採 `NativeError`**(為無文件之衍生值),亦**不比對錯誤訊息文字**(隨系統語系而異)。**重試上限為 3 次**,重試後改走另一條路徑即收斂。主鍵之唯一約束須由資料表自身提供(`PRIMARY KEY`),已於類別註解載明其為 `insert` 與 `save` 之前提;測試資產之建表語句(`toolg/genTestMdbAssets.mjs`)即含之。
659
+
660
+ **3022 涵蓋主鍵與其他唯一索引之衝突,兩者無從由錯誤碼區辨**,故 `insert` 於攔得 3022 後另以 `SELECT 主鍵 WHERE 主鍵 IN (...)` 核對該些主鍵是否確實存在(分批送出,比對依上述不分大小寫之基準):存在者方視為「已存在則跳過」,不存在者即係撞及其他唯一索引,屬影響該筆以外之錯誤而以整批性錯誤 `reject`。若逕將全部 3022 視為主鍵已存在,該類寫入會被靜默吞掉——`insert` 之聚合模式無逐筆結果,呼叫端只會看到 `nInserted` 短少而無從得知原因。此核對僅於確有衝突時才發生,無衝突之常態不增加往返。
661
+
662
+ **批次一次往返:** exe 之協定支援 per-cmd 之 `stopOnError`(預設 `true`)與 per-run 之 `useTransaction`。`insert` 以 `stopOnError: false` 將全部插入語句於單次 exe 呼叫內循序送出且不因單筆衝突而中止,由逐筆結果直接得出精確之 `nInserted`;`save` 每回合以兩次呼叫完成(一次批次預讀、一次批次寫入),N 筆於無衝突時僅需 2 次呼叫而非 2N 次(exe 冷啟約 0.2 秒,逐筆各跑一次即差一個量級)。
663
+
664
+ `insert` 之 `option.returnList` **已實作**。逐筆判定即 exe 之 per-cmd results(與輸入等長、保序),開啟時僅改包裝為逐筆結果陣列而不摺成計數,零推導成本。同批重複主鍵者僅首筆 `nInserted` 為 `1`(同一連線內循序執行,其後同鍵者撞 3022 而跳過),與聚合模式之計數一致。回傳形式之切換為靜態,僅由該選項之取值決定。測試涵蓋兩種取值之形狀、等長保序與對位正確、同批重複主鍵僅首筆為 `1`、`filter` 計數等於聚合模式之 `nInserted`、輸入無效回 `[]`、逐筆元素鍵集合恰為 `{n, nInserted, ok}`、`change` 事件之 `res` 即實際回傳值,以及 `autoGenPk` 為 `false` 與整批性錯誤時仍為整批 `reject` 而不降為逐筆。
665
+
666
+ `delAll` 帶條件時**先以與 `select` 相同之記憶體過濾取得命中數據之主鍵清單,再依主鍵清單分批 `DELETE ... WHERE 主鍵 IN (...)`**(每批 200 筆,以交易包覆令任一批失敗即整體回滾)。不逕將條件送 Jet,因 Access 不支援 `$in`、`$nin`、`$regex` 等運算子,那會出現「`select` 查得到而 `delAll` 刪不到」之不一致。`find` 未給或為空物件時改送單一 `DELETE FROM`(不需主鍵清單即可清空),兩路徑之 `n` 皆取自 Jet 回報之影響列數,故為實際刪除筆數。命中數據若有主鍵值無效者,因無從精確定位而以整批性錯誤 `reject`,不靜默少刪。
667
+
668
+ T8 無須宣告:**單一行程內併發與跨行程併發皆成立**,故依 T8 未於 README 宣告。原子性全由 Jet 引擎提供,本套件每次操作各自開啟連線且不保有行程內狀態,故兩種範圍於後端為同一情形;`opt.useStable` 為 `true`(預設)時另以佇列序列化同一行程內之呼叫,為 `false` 時亦成立。
669
+
670
+ **惟達成上述保證須處理一項後端特性:** Jet 以 `.ldb` 鎖檔協調多連線存取,而本套件為 one-shot 故高頻開關連線,該鎖檔之競爭會間歇令連線開啟失敗(Jet 3734)。**未處理前已實機重現其後果**:跨行程 `save` 之逐筆結果出現 `ok: 0` 而使該行程之寫入靜默遺失(20 筆之 `value` 欄位全失),單一行程內 `useStable` 為 `false` 時亦間歇出現逐筆失敗。處理方式為:exe 於連線開啟失敗時**一律中止其後 cmds**(不受 `stopOnError` 影響——若續跑而其後 cmd 開啟成功並執行,該標記將不再代表「零語句已執行」,重試整批即會重複執行),並於「本次尚未執行任何指令」時回傳 `openFailed` 標記;`jet.mjs` 據以重試整批(線性退避,上限 8 次),且**僅限可重試之鎖競爭類錯誤碼**(3734 已實測、3050 鎖檔無法建立)——密碼錯誤(3031)、檔案不存在、格式不符(3343)等永久性開啟失敗不重試而直接回報。因 `openFailed` 保證尚未執行任何語句,重試不會重複套用寫入。重試耗盡者,`save` 與 `del` 亦以整批性錯誤 `reject` 而不降為逐筆 `ok: 0`(連線失敗影響全部數據,屬 T4 之整批性錯誤)。
671
+
672
+ 實測依據(平台 Windows 11、Node v24.19.0、Jet 4.0 32-bit,測試檔 `test/unit-concurrency.test.mjs`):2 個獨立行程對相同 20 個主鍵併發 `insert`,`nInserted` 總和為 20 且資料表僅 20 筆;2 個行程對同一批 20 個主鍵各寫入 1 個不同欄位,40 個欄位值全數保留;單一行程內 30 個並行 `insert`(`useStable` 兩種取值)之 `nInserted` 總和為 30 且資料表 30 筆;30 個並行 `insert` 於同一主鍵之 `nInserted` 總和為 1;20 個並行 `save` 於同一列(`useStable` 為 `false`)之逐筆結果全為 `ok: 1`。連續 20 輪無失敗(重試機制加入前約每 3 輪出現一次失敗)。
673
+
674
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七個函數與專屬函數 `createStorage` 皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次且依輸入順序,故逐筆 `error` 恆早於整批 `change`。`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中、`selectByPk` 查無數據皆為正常結果而不發 `error`。
675
+
676
+ 建構時 `opt.url` 解析失敗一律 `throw`,理由同 w-orm-reladb。
677
+
678
+ 本套件另有專屬函數 `createStorage`、`genModelsByTabs`、`init`,依 §7 不在本規格範圍內,且皆不與規格所定函數之概念對應。
679
+
680
+ **修正一項會損毀使用者檔案之既有缺陷:** 原先各操作皆經 `w-orm-reladb` 之 `importModels`,其於**每一次**匯入時無條件以 `fs.writeFileSync` 重寫使用者之 model 原始檔(用意為將 `id` 補為主鍵),而本套件每次操作皆匯入一次,等同每次操作都重寫該檔。已實機重現:2 個行程併發操作時,一方正截斷檔案而另一方讀取,`models/users.js` 被截為 0 位元組,其後**所有行程**之操作全數失敗且該檔永久損毀。已改以本套件自有之 `src/importModels.mjs`,其(一)已具主鍵設定者原樣返回而完全不寫檔,(二)保留原檔之行尾字元,否則 Windows 之 CRLF 檔會因被正規化為 LF 而每次皆判定為有變更,(三)確需變更者改以「寫暫存檔後 rename」之原子寫入。修正後全部測試與併發實測後 model 檔皆維持未改動。
681
+
682
+
683
+ **`insertBulk`:已實作。** 全有全無以 **Jet 之交易(`OleDbTransaction`)包覆全部插入語句**達成:exe 於 `useTransaction` 為 `true` 時開啟交易並於任一 cmd 失敗時 `Rollback`,故 `reject` 之後資料庫狀態與呼叫前相同,**無須補償動作**。同批含重複主鍵者亦於交易內被偵測為衝突(同一連線內循序執行,其後同鍵者撞 3022)。未以 `insert` 之別名或轉呼叫實作——`insert` 為 `stopOnError: false` 且不開交易並攔截 3022 以跳過,`insertBulk` 為首錯即停且開交易而不攔截,兩者之送出方式與衝突政策各自獨立。
684
+
685
+ **交易包覆為必要而非優化**:本後端之批次本即被拆為多個 `INSERT` 語句循序送出(Access 不支援一次插入多組 `VALUES`),無交易時前段必然落盤。已實測 200 筆之批次於末筆撞主鍵時,包覆後筆數增量為 0,並以測試斷言。
686
+
687
+ **效能略優於 `insert`**(平台同上,各 3 輪取平均):
688
+
689
+ | 筆數 | `insert` → `insertBulk` |
690
+ |---|---|
691
+ | 100 | 249 ms → 238 ms |
692
+ | 1000 | 493 ms → 416 ms |
693
+ | 5000 | 1648 ms → 1225 ms |
694
+
695
+ 兩者皆為單次 exe 往返,差距來自交易內之寫入得由 Jet 緩衝後一次提交,而 `insert` 之各語句為自動提交。差距不大,提供本函數主要係為語義(全有全無)與跨套件之可替換性。
696
+
697
+ ### w-orm-lowdb
698
+
699
+ 主鍵欄位為 `id`,為無業務語義之識別碼。主鍵欄位目前**固定為 `id`,尚未支援由呼叫端指定**,已於類別註解與 `selectByPk` 註解載明。
700
+
701
+ 已符合 T1–T10 與七函數之全部規格,含 `insert` 之 `option.returnList`,無待處理項目。
702
+
703
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條;Node 內建之 `events.EventEmitter` 具該拋出語義,已停止使用(改用前本套件僅發出 `change` 而未發 `error`,故該語義尚未致災,惟依規格補齊 `error` 事件後即會使未註冊監聽之呼叫端行為分歧)。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中、`selectByPk` 查無數據皆為正常結果而不發 `error`。
704
+
705
+ 逐筆之 `error` 事件係於**整批寫檔成功之後**依輸入順序發出,而非於逐筆處理當下:本套件之逐筆結果須待整檔寫回成功方為定案,寫檔失敗者整批 `reject` 而不回傳逐筆結果,故此順序方符合 T10.1 第 4 項「須於結果定案之後發出」,且逐筆 `error` 仍恆早於整批 `change`。
706
+
707
+ `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值(UUIDv7 格式之 36 碼字串);為 `false` 時 `insert`、`insertBulk` 與 `save` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查以共用之 `procPk` 於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`autoGenPk` 僅為建構層設定,各寫入函數之 `option` 未提供覆寫。`del` 不受此設定影響。**不採 T6 之例外**:主鍵欄位固定為 `id`、無業務語義、型別固定為字串,例外之兩種情形皆不成立。
708
+
709
+ `save` 之「內容相同」判定採合併後比對:以 `merge({}, 現值, 待寫入物件)` 深層合併後與現值比對,相同則不寫入。合併取深層而非淺層,係為與本後端之寫入行為一致——本套件實際寫入之內容即為該深層合併之結果,判定基準與寫入行為一致方能令 `nModified` 忠實反映是否真的寫入。
710
+
711
+ **T7 之原子性由行程內之序列化佇列達成,非條件寫入。** lowdb 之寫入為「整檔讀出、記憶體修改、整檔寫回」,其 API 無條件寫入、無比較並交換、無交易,亦無檔案鎖,故不適用 T7 所稱「條件寫入配合衝突偵測與重試」之形式;達成手段為套件自行序列化:同一資料庫檔案之全部呼叫(以 `path.resolve(opt.url)` 為鍵,含多個實例與不同 `db`/`cl` 者)一律排入同一條 Promise 鏈,依呼叫順序逐一執行,故 `insert` 之「檢查主鍵不存在與寫入」與 `save` 之「查找主鍵與更新或插入」不會與他者交錯。佇列無關閉選項,符合 T7「若須開啟特定設定才能達成,該設定必須預設開啟」。**此保證僅及於單一行程內**,跨行程之情形見下方 T8。
712
+
713
+ 佇列採 Promise 鏈而非「布林旗標配合輪詢等待」(本套件原先之作法):後者之「檢查旗標」與「設定旗標」之間隔有 `await` 而非原子之 test-and-set,多個等候者之輪詢若落在同一批 timer 觸發即會同時進入臨界區;且 `waitFun` 逾時後為 resolve 而非 reject,等滿 200 秒即照樣放行。Promise 鏈為嚴格 FIFO 且無逾時放行,由結構保證互斥。
714
+
715
+ T8 已完成:README 已宣告兩範圍之適用情形。**單一行程內併發成立**,實測依據(Windows 11、Node v24.19.0、lowdb 7,測試檔 `test/unit-concurrency.test.mjs`)含 30 個並行 `insert` 於不同主鍵之 `nInserted` 總和為 30 且資料表 30 筆、30 個並行 `insert` 於同一主鍵之總和為 1、20 個並行 `save` 於同一列各寫入不同欄位之 20 個欄位全數保留、5 個實例對同一檔案各 10 筆並行 `insert` 之總和為 50。
716
+
717
+ **跨行程併發不成立**,因佇列為 module 層變數僅同一行程內共享,lowdb 亦未使用檔案鎖。失效之具體後果有二,皆已實機重現:(一)**整批遺失**——兩行程各持寫入前之整檔快照,後寫者整檔覆蓋,前寫者之數據整筆消失;(二)**寫檔拋錯而整批 `reject`**——lowdb 之寫檔委由 `steno`,其暫存檔名由資料庫檔名推導(`db.json` 對應 `.db.json.tmp`),兩行程共用同一暫存檔,一方完成 `rename` 後另一方即因來源已不存在而拋 `ENOENT`。實測依據:兩個獨立行程對同一檔案各循序 `insert` 200 筆(共 400 筆)並以同一時間點起跑,連續 3 輪皆有一方以 `ENOENT` 中止,最終資料表分別為 200、200、281 筆,發生率 3/3。迴避方式為單一寫入者、呼叫端自備跨行程鎖,或改用具跨行程原子性之後端。
718
+
719
+ 順帶更正本套件 README 原有之一項推測(「更名回 db.json 時似乎非 rename 而是串流寫入」):`steno@4.0.2` 之寫入為 `writeFile(暫存檔)` 後 `rename`,屬原子替換,故不會讀到半截 JSON;跨行程之問題不在讀到破碎檔,而在上述兩項。
720
+
721
+ `opt.useCache` 開啟時,`select` 與 `selectByPk` 之快取為行程內狀態:本行程之寫入會重設快取,他行程之寫入則不會,故跨行程下得讀到過期數據。此僅影響讀取之新鮮度,不影響寫入路徑之判定——快取不參與寫入,各寫入函數一律重新讀檔。該選項預設關閉,且已於類別註解與 README 載明適用於單程序操作。
722
+
723
+ **T4「實例已關閉」條不適用本套件**:本套件不提供 `close()`,lowdb 無連線且無須釋放之資源,故無「實例已關閉」之狀態存在,亦無該條所欲防範之空轉逾時與靜默 fail-open。
724
+
725
+ **`save` 之逐筆失敗路徑(`ok: 0`)於本套件不可達**:逐筆處理皆為記憶體內之比對與合併而不拋錯,讀檔與寫檔之失敗影響全部筆數而屬 T4 之整批性錯誤,依規格以 `reject` 回報而不降為逐筆。該路徑仍依規格保留於實作內。`del` 之逐筆 `ok: 0` 則確實可達(未帶有效主鍵),「單筆失敗不中斷整批」已由該情形測試涵蓋。
726
+
727
+ **`insertBulk`:已實作。** 全有全無之達成無須交易亦無須補償動作:主鍵檢查(含同批重複,以隨檢查同步更新之對照表偵測)於**任何狀態修改之前**一次完成,衝突即於此拋出而尚未修改任何內容;通過檢查者以**單次** `lowdb.write()` 寫回,而該次寫檔為 `steno` 之「寫暫存檔後 rename」原子替換,不會被拆為多次送出,故不存在「前段已落盤」之情形。寫檔本身失敗者,套件於 `writeData` 內還原記憶體狀態後再拋出,令 `reject` 之後記憶體與檔案一致。未以 `insert` 之別名或轉呼叫實作——`insert` 於撞既有主鍵時跳過該筆並續行,`insertBulk` 則於首次撞及即中止整批且不寫入,兩者之流程與衝突政策各自獨立。已測試涵蓋撞既有主鍵、同批重複主鍵、201 筆之批次末筆衝突,皆斷言筆數增量為 0 且既有數據未被改動。
728
+
729
+ **效能與 `insert` 無顯著差異**(Windows 11、Node v24.19.0,各 3 輪取平均):1000 筆 7ms→6ms、5000 筆 11ms→9ms、20000 筆 30ms→26ms。因兩者皆為一次讀檔、記憶體處理、一次寫檔,差距僅在 `insert` 須逐筆查對照表。提供本函數係為語義(全有全無)與跨套件之可替換性。
730
+
731
+ **`insert` 之 `option.returnList`:已實作。** 逐筆判定即逐筆查對照表之結果(與輸入等長、保序),開啟時僅改包裝為逐筆結果陣列而不摺成計數,零推導成本。同批重複主鍵者僅首筆 `nInserted` 為 `1`(對照表隨插入同步更新),與聚合模式之計數一致。回傳形式之切換為靜態,僅由該選項之取值決定。測試涵蓋兩種取值之形狀、等長保序與對位正確、同批重複主鍵僅首筆為 `1`、`filter` 計數等於聚合模式之 `nInserted`、輸入無效回 `[]`、逐筆元素鍵集合恰為 `{n, nInserted, ok}`、`change` 事件之 `res` 即實際回傳值,以及 `autoGenPk` 為 `false` 時仍為整批 `reject` 而不因本選項降為逐筆。
732
+
733
+ **修正之既有缺陷**(皆已由測試斷言):(一)`insert` 之同批重複主鍵原會全數寫入而使資料表出現重複主鍵,因其對照表僅由既有數據建立而未隨插入更新;(二)`delAll` 之 `n` 原取全表筆數而非實際刪除筆數,違反 T3;(三)`save` 之「內容相同」原以「待寫入物件與現值全等」判定,只給部份欄位且值皆相同者會被誤報 `nModified: 1` 並實際寫檔;(四)`save` 之回傳原依路徑而異(更新路徑無 `nInserted`、插入路徑無 `nModified`),違反 T2;(五)`del` 之主鍵未命中原回 `n: 1`、未帶有效主鍵原回 `n: 1` 且未附 `err`。
734
+
735
+ ### w-orm-level
736
+
737
+ 主鍵欄位為 `id`,為無業務語義之識別碼。主鍵欄位目前**固定為 `id`,尚未支援由呼叫端指定**,已於類別註解與 `selectByPk` 註解載明。
738
+
739
+ 已符合 T1–T10 與七函數之全部規格,含 `insert` 之 `option.returnList`,無待處理項目。
740
+
741
+ T10 之實作:`EventEmitter` 採 `wsemi` 之 `evem()`(即 `eventemitter3`),其於 `'error'` 無監聽者時僅回傳 `false` 而不拋出,符合 T10.1 第 2 條。全部事件收斂於共用之 `emitChange(mode, data, res)` 與 `emitError(mode, data, err)` 兩函數,try/catch 由該二處統一保證,`err` 一律經 `getErrMsg` 轉為字串。`change` 之逐筆函數以整批為單位發出一次,`save` 之逐筆插入另發 `mode` 為 `insert` 之事件;`error` 於七函數皆發出,整批性錯誤於 `reject` 之前、逐筆失敗於該筆定案後各一次。`insert` 全數已存在、`save` 合併後內容相同、`del` 主鍵未命中、`delAll` 條件無命中、`selectByPk` 查無數據皆為正常結果而不發 `error`。改版前本套件僅發出 `change` 而完全未發 `error`。
742
+
743
+ `opt.autoGenPk` 預設為 `true`,以 `genIDSeq()` 產生主鍵值(UUIDv7 格式之 36 碼字串,其單調遞增之特性令主鍵接近順序遞增,於 LevelDB 之有序鍵空間下寫入局部性較佳);為 `false` 時 `insert`、`insertBulk` 與 `save` 皆不補值,未帶有效主鍵者以 `Promise.reject` 拋出整批性錯誤。主鍵檢查以共用之 `procPk` 於任何寫入之前一次完成,故整批 `reject` 時同批之有效筆數亦不會被寫入。`autoGenPk` 僅為建構層設定,各寫入函數之 `option` 未提供覆寫。`del` 不受此設定影響。**不採 T6 之例外**:主鍵欄位固定為 `id`、無業務語義、型別固定為字串,例外之兩種情形皆不成立。改版前係以 `genID()` 補值且無此設定。
744
+
745
+ `save` 之「內容相同」判定採合併後比對:以 `merge({}, 現值, 待寫入物件)` 深層合併後與現值比對,相同則不寫入。合併取深層而非淺層,係為與本後端之寫入行為一致——本套件實際寫入之內容即為該深層合併之結果。
746
+
747
+ **T7 之原子性由行程內之序列化佇列達成,非條件寫入。** level(abstract-level 3)之 API 無條件寫入、無比較並交換、無交易,其 `db.batch()` 雖為原子之 WriteBatch 但不具條件語義,故不適用 T7 所稱「條件寫入配合衝突偵測與重試」之形式;達成手段為套件自行序列化:同一實例之七函數(含 `select`)一律排入同一條 Promise 鏈依序執行,故 `insert` 之「檢查主鍵不存在與寫入」與 `save` 之「查找主鍵與更新或插入」不會與他者交錯。佇列無關閉選項,符合 T7「若須開啟特定設定才能達成,該設定必須預設開啟」。各函數之實作為:於臨界區內先以單次 `getMany` 取回全部相關主鍵之現值,逐筆判定後以單次 `batch` 寫入。
748
+
749
+ **佇列掛於實例層而非如 w-orm-lowdb 以資料庫路徑為鍵之 module 層對照表**,因 LevelDB 對資料庫目錄持有 OS 層之獨佔鎖(`LOCK` 檔),同一目錄無從由第二個實例或第二個行程開啟(實測後開者一律以 `LEVEL_DATABASE_NOT_OPEN` 失敗),故單一實例之佇列所涵蓋者即為該目錄之全部操作。
750
+
751
+ T8 已完成:README 已宣告兩範圍之適用情形。**單一行程內併發成立**,實測依據(Windows 11、Node v24.19.0、level 10.0.0、classic-level 3,測試檔 `test/unit-concurrency.test.mjs`)含 30 個並行 `insert` 於不同主鍵之 `nInserted` 總和為 30 且資料表 30 筆、30 個並行 `insert` 於同一主鍵之總和為 1、20 個並行 `save` 於同一列各寫入不同欄位之 20 個欄位全數保留、5 個實例對不同資料表各 10 筆並行 `insert` 之總和為 50。
752
+
753
+ **跨行程併發則為「無從發生」而非「保證失效」**:受上述獨佔鎖所限,本行程持有期間他行程一律於開啟時失敗,故不存在兩個寫入者同時操作同一目錄之情形,亦無計數失準或資料損毀之風險,後開者係明確失敗而非靜默錯亂。實測依據:本行程持有某目錄期間,子行程對同一目錄之 5 筆 `insert` 全數失敗且資料表筆數未變;本行程 `close()` 釋放鎖後同一子行程即正常寫入 5 筆。迴避方式為由單一行程負責全部存取,或改用具跨行程原子性之後端。
754
+
755
+ `opt.useCache` 開啟時,`select` 之快取為行程內狀態,本實例之寫入會重設快取;`selectByPk` 與 `delAll` 一律直讀資料庫而不經快取——前者本即為單鍵直讀,後者不得依過期數據決定刪除對象。該選項預設關閉,且已於類別註解與 README 載明適用於單程序操作。
756
+
757
+ **close() 後之快速失敗:已實作**(T4「實例已關閉」條)。本套件於建構時開啟 level 實例並保有跨呼叫之行程內狀態,且因獨佔鎖之故用畢須 `close()` 方能令該目錄再被開啟,故「實例已關閉」之狀態確實存在。七函數入口共用 `procClosed` 守門,`close()` 後再操作一律於入口立即以整批性錯誤 `reject`(訊息明示 closed,並依 T10.3 發出 `error` 事件),不與「查無資料」同形;`selectByPk` 之守門置於「主鍵值無效回 `null`」之判定之前。`waitOpen` 另作為縱深防禦:其等待條件納入終態(`closed`)並於等待後複檢實際狀態,故**開啟失敗者亦快速失敗**——此點於本後端尤其必要,目錄被他者持有而開啟失敗時 `status` 直接為終態 `closed`,改版前之 `waitFun(() => status === 'open')` 無參數等待將空轉約 200 秒並逐輪 `console.log`,且其後 `getValue` 之 catch 吞下 `LEVEL_DATABASE_NOT_OPEN` 而使 `del` 靜默回「主鍵未命中」之正常結果(fail-open)。
758
+
759
+ **`insertBulk`:已實作。** 全有全無無須交易亦無須補償動作:主鍵檢查(含同批重複,以隨檢查同步更新之對照表偵測)於**任何寫入之前**一次完成,衝突即於此拋出而尚未寫入任何一筆;通過檢查者以**單次** `client.batch()` 寫入,而 LevelDB 之 WriteBatch 為原子且不會被拆為多次送出,故不存在「前段已落盤」之情形。未以 `insert` 之別名或轉呼叫實作——`insert` 於撞既有主鍵時跳過該筆並續行,`insertBulk` 則於首次撞及即中止整批且不寫入,兩者之流程與衝突政策各自獨立。已測試涵蓋撞既有主鍵、同批重複主鍵、201 筆之批次末筆衝突,皆斷言筆數增量為 0 且既有數據未被改動。
760
+
761
+ **效能與 `insert` 無顯著差異**(Windows 11、Node v24.19.0,各 3 輪取平均,已排除資料庫開啟成本):1000 筆 6ms→5ms、5000 筆 15ms→14ms、20000 筆 52ms→55ms。因兩者皆為一次 `getMany` 與一次 `batch`,差距僅在 `insert` 須逐筆查對照表。提供本函數係為語義(全有全無)與跨套件之可替換性。
762
+
763
+ **`insert` 之 `option.returnList`:已實作。** 逐筆判定即逐筆查對照表之結果(與輸入等長、保序),開啟時僅改包裝為逐筆結果陣列而不摺成計數,零推導成本。同批重複主鍵者僅首筆 `nInserted` 為 `1`(對照表隨插入同步更新),與聚合模式之計數一致。回傳形式之切換為靜態,僅由該選項之取值決定。測試涵蓋兩種取值之形狀、等長保序與對位正確、同批重複主鍵僅首筆為 `1`、`filter` 計數等於聚合模式之 `nInserted`、輸入無效回 `[]`、逐筆元素鍵集合恰為 `{n, nInserted, ok}`、`change` 事件之 `res` 即實際回傳值,以及 `autoGenPk` 為 `false` 時仍為整批 `reject` 而不因本選項降為逐筆。
764
+
765
+ **`save` 之逐筆失敗路徑(`ok: 0`)於本套件不可達**:逐筆處理皆為記憶體內之比對與合併而不拋錯,批次取值與批次寫入之失敗影響全部筆數而屬 T4 之整批性錯誤,依規格以 `reject` 回報而不降為逐筆。該路徑仍依規格保留於實作內。`del` 之逐筆 `ok: 0` 則確實可達(未帶有效主鍵),「單筆失敗不中斷整批」已由該情形測試涵蓋。
766
+
767
+ **修正之既有缺陷**(皆已由測試斷言):(一)缺 `selectByPk`、`insertBulk`、`close` 三個函數;(二)完全未發出 `error` 事件;(三)無 `autoGenPk`;(四)`insert` 與 `save` 為「先讀出、再依讀到的結果決定寫入」而違反 T7;(五)`delAll` 之 `n` 原取全表筆數而非實際刪除筆數,違反 T3;(六)`save` 之「內容相同」原以「待寫入物件與現值全等」判定,只給部份欄位且值皆相同者會被誤報 `nModified: 1` 並實際寫入;(七)`save` 之回傳原依路徑而異(更新路徑無 `nInserted`、自動插入路徑轉呼叫 `insert` 而無 `nModified`),違反 T2,且命中而內容相同者原回 `n: 0`;(八)`del` 之主鍵未命中原回 `n: 1`、未帶有效主鍵原回 `n: 1` 且未附 `err`;(九)close 或開啟失敗後之空轉約 200 秒與 `del` 之靜默 fail-open(見上方 T4 條)。
@@ -1,7 +0,0 @@
1
- /*!
2
- * req-mingo v1.0.19
3
- * (c) 2018-2021 yuda-lyu(semisphere)
4
- * Released under the MIT License.
5
- */
6
- !function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o(require("mingo")):"function"==typeof define&&define.amd?define(["mingo"],o):(e="undefined"!=typeof globalThis?globalThis:e||self)["req-mingo"]=o(e.mingo)}(this,function(e){"use strict";function o(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}return o(e)});
7
- //# sourceMappingURL=req-mingo.umd.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"req-mingo.umd.js","sources":["../src/reqMingo.js"],"sourcesContent":null,"names":["require$$0"],"mappings":";;;;;2XAAYA"}