@zhushanwen/session-delivery 2.1.0 → 2.2.0

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.
package/dist/index.d.mts CHANGED
@@ -202,7 +202,7 @@ interface DeliveryStoreRecord {
202
202
  meta?: DeliveryMeta;
203
203
  }
204
204
  /**
205
- * 持久化存储端口(v3,统一语言词条「持久化存储端口」):内核与耐久面的唯一接口。
205
+ * 持久化存储端口(v3,统一语言词条「持久化存储端口」):内核与耐久层的唯一接口。
206
206
  * 两个实现在两半区适配层(本包零 fs 零 pi 知识):runtime 半区 = 磁盘 outbox 文件
207
207
  * (文本投递路径,波 4 接线);port 半区 = session entry 读写(通知投递路径,波 2 接线)。
208
208
  *
@@ -210,8 +210,12 @@ interface DeliveryStoreRecord {
210
210
  * - persist:send 受理前调用(耐久承诺先于投递尝试,与账本 record 先于 deliver 同
211
211
  * 律)。persist 抛错 = 承诺未落 → 内核拒收该条目(send 返回 persist-failed /
212
212
  * sendChecked 同步 throw),不进入投递流程。
213
- * - ack:终局 delivered / cancelled 后调用(写持久终局——已撤销消息的防复活凭据
214
- * 同样在持久终局记录里)。ack 抛错不回滚状态机(终局事实已发生),warn 留痕。
213
+ * - ack:终局 delivered / cancelled 时调用(写持久终局——已撤销消息的防复活凭据
214
+ * 同样在持久终局记录里)。磁盘权威(2026-10-30 用户裁决「内存终态必须以磁盘确认为
215
+ * 准」):ack 先于内存终态登记,抛错 = 持久终局未写入 → 内核不登记内存终态(条目
216
+ * 留守活跃集非终态)并显式上报分型错误 DeliveryAckPersistError;外部送达事实不
217
+ * 回滚,恢复后可能重投(at-least-once 显式接受)。实现方须遵守同序:自身内存派生
218
+ * 数据只在落盘成功后登记。
215
219
  * - discard:forget 失败条目的记录无痕移除(无终局无判重语义——区别于 ack,不进
216
220
  * 终局记录,防重启后 loadUndelivered 把已弃条目当未投递复活)。
217
221
  * - loadUndelivered:供重启 / 重放时重入队(装配方启动时取回未终局记录重新提交)。
@@ -228,7 +232,7 @@ interface DeliveryStoreRecord {
228
232
  interface DeliveryStore {
229
233
  /** 受理前写入记录(耐久承诺先于投递尝试)。抛错 = 承诺未写入,内核拒收。 */
230
234
  persist(record: DeliveryStoreRecord): void;
231
- /** 终局(delivered / cancelled)后写持久终局(从待投差集移除、进终局记录)。 */
235
+ /** 终局(delivered / cancelled)写持久终局(从待投差集移除、进终局记录)。抛错 = 持久终局未写入 → 内核不登记内存终态(磁盘权威,见上方契约)。 */
232
236
  ack(id: string): void;
233
237
  /** forget 失败条目的记录无痕移除(无终局形态,区别于 ack)。 */
234
238
  discard(id: string): void;
@@ -272,7 +276,7 @@ interface DeliveryConfig {
272
276
  */
273
277
  onSettled?: (msg: DeliveryMessage, outcome: 'delivered' | 'rejected') => void;
274
278
  /**
275
- * 持久化存储端口(v3)。缺省 undefined = 无耐久面(v2 行为:纯内存条目集)——
279
+ * 持久化存储端口(v3)。缺省 undefined = 无耐久层(v2 行为:纯内存条目集)——
276
280
  * 既有 runtime 装配不传 = 逐字节不变(波 1 零行为变化锚)。提供时内核行为契约见
277
281
  * DeliveryStore(受理前 persist / 终局 ack / forget 失败 discard / 构造期
278
282
  * loadTerminalIds 播种判重索引)。
@@ -459,10 +463,12 @@ interface DeliveryEntriesProjection {
459
463
  * v3 增量(统一消息投递内核,设计 unified-delivery §3.5):
460
464
  * - DeliveryStore 端口(config.store):send/sendChecked 受理前先 persist(耐久承诺
461
465
  * 先于投递尝试;persist 抛错 = 承诺未落 → 拒收 rejected('persist-failed'),条目
462
- * 回滚不入队不投递);终局 delivered / cancelled 后 ack(finalizeEntry / drain);
463
- * forget 失败条目 discard(无终局语义的记录无痕移除,防重启复活)。ack / 判重
464
- * 查询的 store 异常降级 warn 留痕、状态机照常(终局事实已发生 / 探测异常降级
465
- * 保守放行);persist 异常 fail-fast 拒收(承诺语义)。
466
+ * 回滚不入队不投递);终局 delivered / cancelled 时 ack(finalizeEntry / drain);
467
+ * forget 失败条目 discard(无终局语义的记录无痕移除,防重启复活)。ack 先于内存
468
+ * 终态登记(磁盘权威,2026-10-30 用户裁决「内存终态必须以磁盘确认为准」):写盘
469
+ * 失败 → 内存不登记终态(条目留守活跃集非终态)+ 分型错误 DeliveryAckPersistError
470
+ * 显式上报——外部送达事实不回滚,恢复后可能重投(at-least-once 显式接受);判重
471
+ * 查询(hasTerminal)异常保守放行 + warn;persist 异常 fail-fast 拒收(承诺语义)。
466
472
  * - 送达凭据三档(receiptAnchor):'details-id' 与 'marker' / 'acceptance' 并列
467
473
  * (ADR-0074 词条扩展)。details-id 条目受理后维持 in-flight 等回执(同 marker);
468
474
  * 判定逻辑以可注入凭据检查函数(config.proofCheck)承载,调用点 =
@@ -652,7 +658,10 @@ interface DeliveryHandleV2 extends DeliveryHandle {
652
658
  * msg.payload 同步替换引用(onSettled per-message 契约的消费方拿到替换后内容)。
653
659
  */
654
660
  edit(id: string, payload: DeliveryPayload): boolean;
655
- /** 全量取回文本(forceQuit,D9③):条目清空 + 全部记 cancelled tombstone。 */
661
+ /**
662
+ * 全量取回文本(forceQuit,D9③):条目清空 + 全部记 cancelled tombstone(ack 写盘
663
+ * 失败的条目除外——文本照常返回,条目转 failed 留守,见 drain 实现的磁盘权威序注释)。
664
+ */
656
665
  drain(): DrainResult;
657
666
  }
658
667
  /** cancel(id) 结果(D-3 偏差:cancelled 形态携带条目快照含 payload 全文供草稿恢复)。 */
@@ -686,7 +695,7 @@ declare function createDelivery(port: DeliveryPort, config?: DeliveryConfigWithW
686
695
  *
687
696
  * 判别形态依据(设计 §3.3 D4 尾注):本包与消费方(runtime registry)是进程内同包
688
697
  * 调用边界——编译期类型可得,instanceof 是最简判别且免词表;与跨进程 WS 边界的
689
- * 分类码形态(CompactErrorCode)不构成双轨。
698
+ * error envelope 分类码形态不构成双轨。
690
699
  */
691
700
  /**
692
701
  * 「用户主动回收」对挂起 sendChecked waiter 的 reject 错误类。
@@ -708,6 +717,28 @@ declare class DeliveryReclaimError extends Error {
708
717
  readonly entryId: string | undefined;
709
718
  constructor(kind: 'cancelled' | 'drained', entryId?: string);
710
719
  }
720
+ /**
721
+ * 「终局事实已发生,但持久终局(store.ack)写入失败」的分型错误(2026-10-30 用户裁决
722
+ * 「磁盘为准」:内存终态必须以磁盘确认为准)。
723
+ *
724
+ * 产生点(delivery.ts ackStore):confirmDelivered / cancel / drain 终局化时先写持久
725
+ * 终局,store.ack 抛错即包装为本类——内核不登记内存终态(条目留守活跃集非终态,判重
726
+ * 与恢复以磁盘为唯一权威),由各终局入口捕获留痕并显式上报。
727
+ *
728
+ * 语义边界:外部送达事实不可回滚(消息已实际发出)——写盘失败期间该条不视为已终态,
729
+ * 恢复(重启重放 / 下一回执边沿重驱动)后可能重投,at-least-once 语义被显式接受
730
+ * (宁可可能重投,不做内存单方终态)。
731
+ *
732
+ * 结构化字段:
733
+ * - entryId:终局写入失败的条目 id。
734
+ * - state:本应落定的终态('delivered' | 'cancelled')——文案按它分叉:delivered 强调
735
+ * 送达事实已成立;cancelled 强调本地撤销意图已发生但持久撤销凭据未落。
736
+ */
737
+ declare class DeliveryAckPersistError extends Error {
738
+ readonly entryId: string;
739
+ readonly state: 'delivered' | 'cancelled';
740
+ constructor(entryId: string, state: 'delivered' | 'cancelled', cause?: unknown);
741
+ }
711
742
 
712
743
  /**
713
744
  * 磁盘 outbox —— DeliveryStore 的 runtime 半区实现(统一消息投递设计 §3.5.1 / §3.8
@@ -731,6 +762,8 @@ declare class DeliveryReclaimError extends Error {
731
762
  * 设计 §3.5.1「两个实现在两半区适配层」);目录与文件路径由装配方(runtime 侧)动态
732
763
  * 推导后以字符串传入。崩溃写入的中途半行由读取容错覆盖(JSON.parse 失败 warn 跳过)
733
764
  * ——最坏丢一行 = 最坏多投一次,与「必达优先于去重」语义一致,不需要原子替换协议。
765
+ * 读失败(文件存在但不可读)显式分型上抛 DiskStoreReadError,不折叠成空结果(见
766
+ * ensureLoaded 注释);文件不存在 = 合法空态。
734
767
  */
735
768
 
736
769
  /** fs 窄接口(注入点;生产 = node:fs 适配,测试 = 内存替身)。 */
@@ -762,6 +795,16 @@ interface DiskDeliveryStore extends DeliveryStore {
762
795
  */
763
796
  attachSegments(id: string, segments: readonly unknown[]): void;
764
797
  }
798
+ /**
799
+ * outbox 读失败错误(显式分型:读失败 ≠ 空文件)。文件存在但不可读(权限 / 瞬时 fs
800
+ * 故障)时由 ensureLoaded 抛出——判别形态与 DeliveryReclaimError 同理(进程内同包
801
+ * 调用边界,instanceof 最简判别)。
802
+ */
803
+ declare class DiskStoreReadError extends Error {
804
+ /** outbox 文件路径(诊断定位)。 */
805
+ readonly file: string;
806
+ constructor(file: string, cause?: unknown);
807
+ }
765
808
  declare function createDiskDeliveryStore(deps: DiskDeliveryStoreDeps): DiskDeliveryStore;
766
809
 
767
- export { type CustomPayload, type DeliveryCancelResult, type DeliveryConfig, type DeliveryConfigWithWarn, type DeliveryDeferReason, type DeliveryEntriesFull, type DeliveryEntriesProjection, type DeliveryEntry, type DeliveryEntryState, type DeliveryFailurePolicy, type DeliveryHandle, type DeliveryHandleV2, type DeliveryIntent, type DeliveryLane, type DeliveryMessage, type DeliveryMeta, type DeliveryPayload, type DeliveryPort, type DeliveryProofCheck, type DeliveryReceiptAnchor, DeliveryReclaimError, type DeliverySendResult, type DeliveryStore, type DeliveryStoreRecord, type DeliverySubmitOptions, type DeliveryTerminalRecord, type DeliveryWarnSink, type DiskDeliveryStore, type DiskDeliveryStoreDeps, type DiskStoreFs, type DrainResult, type SendReceipt, type TextPayload, createDelivery, createDiskDeliveryStore };
810
+ export { type CustomPayload, DeliveryAckPersistError, type DeliveryCancelResult, type DeliveryConfig, type DeliveryConfigWithWarn, type DeliveryDeferReason, type DeliveryEntriesFull, type DeliveryEntriesProjection, type DeliveryEntry, type DeliveryEntryState, type DeliveryFailurePolicy, type DeliveryHandle, type DeliveryHandleV2, type DeliveryIntent, type DeliveryLane, type DeliveryMessage, type DeliveryMeta, type DeliveryPayload, type DeliveryPort, type DeliveryProofCheck, type DeliveryReceiptAnchor, DeliveryReclaimError, type DeliverySendResult, type DeliveryStore, type DeliveryStoreRecord, type DeliverySubmitOptions, type DeliveryTerminalRecord, type DeliveryWarnSink, type DiskDeliveryStore, type DiskDeliveryStoreDeps, type DiskStoreFs, DiskStoreReadError, type DrainResult, type SendReceipt, type TextPayload, createDelivery, createDiskDeliveryStore };
package/dist/index.mjs CHANGED
@@ -9,6 +9,19 @@ var DeliveryReclaimError = class extends Error {
9
9
  this.entryId = entryId;
10
10
  }
11
11
  };
12
+ var DeliveryAckPersistError = class extends Error {
13
+ entryId;
14
+ state;
15
+ constructor(entryId, state, cause) {
16
+ super(
17
+ state === "delivered" ? `delivery delivered but terminal record not persisted; recovery may redeliver (id=${entryId})` : `entry cancelled locally but terminal record not persisted; recovery may redeliver (id=${entryId})`,
18
+ { cause }
19
+ );
20
+ this.name = "DeliveryAckPersistError";
21
+ this.entryId = entryId;
22
+ this.state = state;
23
+ }
24
+ };
12
25
 
13
26
  // src/lru.ts
14
27
  var LruSet = class {
@@ -144,12 +157,12 @@ function createDelivery(port, config) {
144
157
  meta: e.msg.meta
145
158
  });
146
159
  }
147
- function ackStore(id) {
160
+ function ackStore(id, state) {
148
161
  if (!store) return;
149
162
  try {
150
163
  store.ack(id);
151
164
  } catch (err) {
152
- warn(`store.ack failed (terminal record not persisted; id=${id})`, err);
165
+ throw new DeliveryAckPersistError(id, state, err);
153
166
  }
154
167
  }
155
168
  function safeHasTerminal(id) {
@@ -203,6 +216,7 @@ function createDelivery(port, config) {
203
216
  return false;
204
217
  }
205
218
  function finalizeEntry(e, state) {
219
+ ackStore(e.id, state);
206
220
  const batchIdx = inflightBatch.indexOf(e);
207
221
  if (batchIdx !== -1) inflightBatch.splice(batchIdx, 1);
208
222
  const idx = active.indexOf(e);
@@ -214,7 +228,6 @@ function createDelivery(port, config) {
214
228
  e.settledAt = ts;
215
229
  terminalIndex.add(e.id);
216
230
  terminalRecords.set(e.id, { id: e.id, state, lane: e.lane, settledAt: ts });
217
- ackStore(e.id);
218
231
  if (state === "delivered") {
219
232
  resolveWaitersOf(e);
220
233
  deliveredLog.push(e);
@@ -222,6 +235,25 @@ function createDelivery(port, config) {
222
235
  }
223
236
  notifyChange();
224
237
  }
238
+ function parkAsFailed(e) {
239
+ const batchIdx = inflightBatch.indexOf(e);
240
+ if (batchIdx !== -1) inflightBatch.splice(batchIdx, 1);
241
+ const ts = now();
242
+ e.state = "failed";
243
+ e.updatedAt = ts;
244
+ e.settledAt = ts;
245
+ notifyChange();
246
+ }
247
+ function cancelFinalize(e) {
248
+ try {
249
+ finalizeEntry(e, "cancelled");
250
+ } catch (err) {
251
+ if (!(err instanceof DeliveryAckPersistError)) throw err;
252
+ warn(`store.ack failed; cancel not terminalized, entry parked failed (id=${e.id})`, err);
253
+ parkAsFailed(e);
254
+ throw err;
255
+ }
256
+ }
225
257
  function removeActive(e) {
226
258
  const idx = active.indexOf(e);
227
259
  if (idx !== -1) active.splice(idx, 1);
@@ -538,7 +570,13 @@ function createDelivery(port, config) {
538
570
  const e = activeIndex.get(id);
539
571
  if (!e) return false;
540
572
  if (e.state !== "queued" && e.state !== "in-flight" && e.state !== "failed") return false;
541
- finalizeEntry(e, "delivered");
573
+ try {
574
+ finalizeEntry(e, "delivered");
575
+ } catch (err) {
576
+ if (!(err instanceof DeliveryAckPersistError)) throw err;
577
+ warn(`store.ack failed; terminal not registered, entry stays non-terminal (id=${id})`, err);
578
+ return false;
579
+ }
542
580
  if (!disposed) callOnSettled(e.msg, "delivered");
543
581
  return true;
544
582
  }
@@ -625,11 +663,11 @@ function createDelivery(port, config) {
625
663
  return { kind: "reclaim-requested", entry: snapshot(e) };
626
664
  }
627
665
  rejectWaitersOf(e, new DeliveryReclaimError("cancelled", id));
628
- finalizeEntry(e, "cancelled");
666
+ cancelFinalize(e);
629
667
  return { kind: "cancelled", entry: snapshot(e) };
630
668
  }
631
669
  rejectWaitersOf(e, new DeliveryReclaimError("cancelled", id));
632
- finalizeEntry(e, "cancelled");
670
+ cancelFinalize(e);
633
671
  return { kind: "cancelled", entry: snapshot(e) };
634
672
  }
635
673
  function edit(id, payload) {
@@ -667,17 +705,28 @@ function createDelivery(port, config) {
667
705
  function drain() {
668
706
  if (disposed) return [];
669
707
  const drained = active.slice();
670
- active.length = 0;
671
- activeIndex.clear();
708
+ const ts = now();
709
+ const result = [];
710
+ for (const e of drained) {
711
+ try {
712
+ ackStore(e.id, "cancelled");
713
+ const idx = active.indexOf(e);
714
+ if (idx !== -1) active.splice(idx, 1);
715
+ activeIndex.delete(e.id);
716
+ e.state = "cancelled";
717
+ e.updatedAt = ts;
718
+ e.settledAt = ts;
719
+ terminalIndex.add(e.id);
720
+ terminalRecords.set(e.id, { id: e.id, state: "cancelled", lane: e.lane, settledAt: ts });
721
+ } catch (err) {
722
+ if (!(err instanceof DeliveryAckPersistError)) throw err;
723
+ warn(`store.ack failed in drain; entry not terminalized, parked failed (id=${e.id})`, err);
724
+ parkAsFailed(e);
725
+ }
726
+ result.push({ id: e.id, payload: e.payload });
727
+ }
672
728
  inflightBatch = [];
673
729
  inFlight = false;
674
- const ts = now();
675
- const result = drained.map((e) => {
676
- terminalIndex.add(e.id);
677
- terminalRecords.set(e.id, { id: e.id, state: "cancelled", lane: e.lane, settledAt: ts });
678
- ackStore(e.id);
679
- return { id: e.id, payload: e.payload };
680
- });
681
730
  for (const w of checkedPending) {
682
731
  w.reject(new DeliveryReclaimError("drained"));
683
732
  }
@@ -710,6 +759,15 @@ function serializeLine(line) {
710
759
  return `${JSON.stringify(line)}
711
760
  `;
712
761
  }
762
+ var DiskStoreReadError = class extends Error {
763
+ /** outbox 文件路径(诊断定位)。 */
764
+ file;
765
+ constructor(file, cause) {
766
+ super(`disk delivery store read failed: ${file}`, { cause });
767
+ this.name = "DiskStoreReadError";
768
+ this.file = file;
769
+ }
770
+ };
713
771
  function createDiskDeliveryStore(deps) {
714
772
  const { fs, dir, file } = deps;
715
773
  const log = deps.log ?? ((message, err) => {
@@ -742,15 +800,18 @@ function createDiskDeliveryStore(deps) {
742
800
  }
743
801
  function ensureLoaded() {
744
802
  if (loaded) return;
745
- loaded = true;
746
- if (!fs.existsSync(file)) return;
803
+ if (!fs.existsSync(file)) {
804
+ loaded = true;
805
+ return;
806
+ }
747
807
  let raw;
748
808
  try {
749
809
  raw = fs.readFileSync(file, "utf8");
750
810
  } catch (err) {
751
- log(`[disk-delivery-store] read failed (treated as empty), file=${file}`, err);
752
- return;
811
+ log(`[disk-delivery-store] read failed (propagating; not treated as empty), file=${file}`, err);
812
+ throw new DiskStoreReadError(file, err);
753
813
  }
814
+ loaded = true;
754
815
  let lineNumber = 0;
755
816
  for (const lineText of raw.split("\n")) {
756
817
  lineNumber += 1;
@@ -818,7 +879,9 @@ function createDiskDeliveryStore(deps) {
818
879
  };
819
880
  }
820
881
  export {
882
+ DeliveryAckPersistError,
821
883
  DeliveryReclaimError,
884
+ DiskStoreReadError,
822
885
  createDelivery,
823
886
  createDiskDeliveryStore
824
887
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/session-delivery",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Session message delivery kernel: zero-pi-dependency strategy layer for idempotent, accept-time dispatch of messages into pi sessions",
5
5
  "main": "dist/index.mjs",
6
6
  "module": "dist/index.mjs",
@@ -25,6 +25,6 @@
25
25
  "scripts": {
26
26
  "build": "tsup",
27
27
  "typecheck": "tsc --noEmit",
28
- "test": "vitest run"
28
+ "test": "vitest run --pool=threads"
29
29
  }
30
30
  }