@h-ai/reach 0.1.0-alpha.73 → 0.1.0-alpha.75

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/README.en.md CHANGED
@@ -217,6 +217,14 @@ Use `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` for passwo
217
217
 
218
218
  `migrateReach({ dryRun: true })` previews supported migrations, applied during repository initialization. Statuses/identifiers remain; legacy in-flight work becomes unknown. Legacy payloads are retired once and pending old payloads discarded. Back up and stop old writers before upgrading; never replay old credentials.
219
219
 
220
+ ## API acknowledgements and destination boundaries
221
+
222
+ An empty successful 2xx response, including 204/205, means receiver acceptance; messageId is optional and acceptance does not prove final delivery. Nonempty responses must be JSON objects with an optional messageId string of at most 255 characters. Responses are bounded to 64 KiB. Invalid responses, timeouts and network failures never become false success or expose receiver content.
223
+
224
+ API `redirect` defaults to `error`; ordinary messages may explicitly use `follow`. Sensitive messages always reject redirects without forwarding payloads or private headers. `requireHttps:true` enforces initial HTTPS and cannot be combined with follow. When omitted, production sensitive messages require HTTPS; explicit false is only for an operator-approved trusted internal connection. Unknown source-side effects still require reconciliation and sensitive messages are never retried automatically. Application allowlists and network egress controls remain necessary.
225
+
226
+ PostgreSQL/MySQL queue claims, renewals and final acknowledgements use the database execution clock inside SQL; batch cutoffs also come from the database. Node clock skew and row-lock waits cannot revive an expired owner. Local SQLite remains a development/test backend with a controllable test clock.
227
+
220
228
  ## Development and testing
221
229
 
222
230
  ```bash
package/README.ja.md CHANGED
@@ -217,6 +217,14 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
217
217
 
218
218
  `migrateReach({ dryRun: true })` で事前確認し、リポジトリー初期化で対応する移行を実行します。状態と識別子を保持し、旧処理中は unknown、旧ペイロードは一度廃棄し、旧待機ペイロードは discarded にします。更新前は旧書込を停止しバックアップし、古い認証情報を再送しないでください。
219
219
 
220
+ ## API 応答と送信先の境界
221
+
222
+ 204/205 を含む空の成功 2xx は受付成功を表し、messageId は任意で、最終配信の証明ではありません。本文がある場合は JSON オブジェクトとし、messageId は255文字以下の任意文字列です。応答上限は64 KiBです。不正応答・タイムアウト・通信失敗を成功扱いにせず、応答本文をログへ出しません。
223
+
224
+ API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
225
+
226
+ PostgreSQL/MySQLの取得・更新・最終確認はSQL実行時のDB時計を使い、走査期限もDBから取得します。ノード時差や行ロック待機で期限切れ所有者を復活させません。SQLiteの制御可能な時計は開発・テスト専用です。
227
+
220
228
  ## 開発とテスト
221
229
 
222
230
  ```bash
package/README.md CHANGED
@@ -217,6 +217,14 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
217
217
 
218
218
  `migrateReach({ dryRun: true })` 预检;仓库初始化自动执行受支持步骤。升级保留发送状态与标识,旧在途转 unknown;旧正文/主题/模板变量/extra 一次性退役,待发旧载荷 discarded。生产需先备份停旧写入,不能恢复旧凭据重新投递。
219
219
 
220
+ ## API 回执与目标边界
221
+
222
+ 空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
223
+
224
+ API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
225
+
226
+ PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
227
+
220
228
  ## 开发测试
221
229
 
222
230
  ```bash
package/dist/index.d.ts CHANGED
@@ -80,6 +80,11 @@ declare const ApiProviderConfigSchema: z.ZodObject<{
80
80
  }>>;
81
81
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
82
82
  timeout: z.ZodDefault<z.ZodNumber>;
83
+ redirect: z.ZodDefault<z.ZodEnum<{
84
+ error: "error";
85
+ follow: "follow";
86
+ }>>;
87
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
83
88
  idempotent: z.ZodDefault<z.ZodBoolean>;
84
89
  }, z.core.$strip>;
85
90
  /**
@@ -120,6 +125,11 @@ declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
120
125
  }>>;
121
126
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
122
127
  timeout: z.ZodDefault<z.ZodNumber>;
128
+ redirect: z.ZodDefault<z.ZodEnum<{
129
+ error: "error";
130
+ follow: "follow";
131
+ }>>;
132
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
123
133
  idempotent: z.ZodDefault<z.ZodBoolean>;
124
134
  }, z.core.$strip>], "type">;
125
135
  /**
@@ -344,6 +354,11 @@ declare const ReachConfigSchema: z.ZodObject<{
344
354
  }>>;
345
355
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
346
356
  timeout: z.ZodDefault<z.ZodNumber>;
357
+ redirect: z.ZodDefault<z.ZodEnum<{
358
+ error: "error";
359
+ follow: "follow";
360
+ }>>;
361
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
347
362
  idempotent: z.ZodDefault<z.ZodBoolean>;
348
363
  }, z.core.$strip>], "type">>;
349
364
  templates: z.ZodOptional<z.ZodArray<z.ZodObject<{
package/dist/index.js CHANGED
@@ -53,7 +53,11 @@ var en_US_default = {
53
53
  reach_productionProviderRequired: "Production requires a delivery provider; console is available only in development or explicit test mode",
54
54
  reach_sensitivePolicyRequired: "Credential notifications require a database receipt, stable idempotency key and expiry",
55
55
  reach_sensitiveDeliveryUnconfirmed: "Credential delivery is unconfirmed and will not be retried automatically. Check the receipt or request new credentials.",
56
- reach_messageExpired: "The credentials expired before delivery"
56
+ reach_messageExpired: "The credentials expired before delivery",
57
+ reach_apiResponseTooLarge: "API acknowledgement exceeds the 64 KiB limit",
58
+ reach_apiResponseInvalid: "API acknowledgement must be empty or a JSON object with an optional messageId string",
59
+ reach_apiRequestFailed: "API request failed or timed out; delivery is unconfirmed",
60
+ reach_apiHttpsRequired: "This API policy requires HTTPS and disallows automatic redirects"
57
61
  };
58
62
 
59
63
  // messages/ja-JP.json
@@ -101,7 +105,11 @@ var ja_JP_default = {
101
105
  reach_productionProviderRequired: "\u672C\u756A\u74B0\u5883\u306B\u306F\u914D\u4FE1\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059\u3002console \u306F\u958B\u767A\u74B0\u5883\u307E\u305F\u306F\u660E\u793A\u7684\u306A\u30C6\u30B9\u30C8\u30E2\u30FC\u30C9\u3067\u306E\u307F\u4F7F\u7528\u3067\u304D\u307E\u3059",
102
106
  reach_sensitivePolicyRequired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u901A\u77E5\u306B\u306F\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u306E\u53D7\u9818\u8A18\u9332\u3001\u56FA\u5B9A\u306E\u51AA\u7B49\u30AD\u30FC\u3068\u6709\u52B9\u671F\u9650\u304C\u5FC5\u8981\u3067\u3059",
103
107
  reach_sensitiveDeliveryUnconfirmed: "\u8A8D\u8A3C\u60C5\u5831\u306E\u914D\u4FE1\u3092\u78BA\u8A8D\u3067\u304D\u307E\u305B\u3093\u3002\u81EA\u52D5\u518D\u9001\u305B\u305A\u3001\u53D7\u9818\u8A18\u9332\u306E\u78BA\u8A8D\u307E\u305F\u306F\u8A8D\u8A3C\u60C5\u5831\u306E\u518D\u767A\u884C\u304C\u5FC5\u8981\u3067\u3059\u3002",
104
- reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F"
108
+ reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F",
109
+ reach_apiResponseTooLarge: "API \u5FDC\u7B54\u304C 64 KiB \u306E\u4E0A\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
110
+ reach_apiResponseInvalid: "API \u5FDC\u7B54\u306F\u7A7A\u304B\u3001\u7701\u7565\u53EF\u80FD\u306A messageId \u6587\u5B57\u5217\u3092\u6301\u3064 JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
111
+ reach_apiRequestFailed: "API \u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F\u3002\u914D\u4FE1\u7D50\u679C\u306F\u672A\u78BA\u8A8D\u3067\u3059",
112
+ reach_apiHttpsRequired: "\u3053\u306E API \u30DD\u30EA\u30B7\u30FC\u3067\u306F HTTPS \u304C\u5FC5\u8981\u3067\u3001\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u306F\u7981\u6B62\u3055\u308C\u3066\u3044\u307E\u3059"
105
113
  };
106
114
 
107
115
  // messages/zh-CN.json
@@ -149,7 +157,11 @@ var zh_CN_default = {
149
157
  reach_productionProviderRequired: "\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u914D\u7F6E\u771F\u5B9E\u89E6\u8FBE\u670D\u52A1\uFF1Bconsole \u4EC5\u53EF\u7528\u4E8E\u5F00\u53D1\u6216\u663E\u5F0F\u6D4B\u8BD5\u6A21\u5F0F",
150
158
  reach_sensitivePolicyRequired: "\u51ED\u636E\u901A\u77E5\u9700\u8981\u6570\u636E\u5E93\u56DE\u6267\u3001\u7A33\u5B9A\u5E42\u7B49\u952E\u548C\u6709\u6548\u671F",
151
159
  reach_sensitiveDeliveryUnconfirmed: "\u51ED\u636E\u6295\u9012\u5C1A\u672A\u786E\u8BA4\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u91CD\u590D\u53D1\u9001\uFF0C\u8BF7\u67E5\u8BE2\u56DE\u6267\u6216\u7533\u8BF7\u65B0\u51ED\u636E",
152
- reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012"
160
+ reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012",
161
+ reach_apiResponseTooLarge: "API \u56DE\u6267\u8D85\u8FC7 64 KiB \u4E0A\u9650",
162
+ reach_apiResponseInvalid: "API \u56DE\u6267\u5FC5\u987B\u4E3A\u7A7A\u6216\u542B\u53EF\u9009 messageId \u5B57\u7B26\u4E32\u7684 JSON \u5BF9\u8C61",
163
+ reach_apiRequestFailed: "API \u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
164
+ reach_apiHttpsRequired: "\u6B64 API \u7B56\u7565\u8981\u6C42 HTTPS \u4E14\u7981\u6B62\u81EA\u52A8\u91CD\u5B9A\u5411"
153
165
  };
154
166
 
155
167
  // src/reach-i18n.ts
@@ -209,6 +221,10 @@ var ApiProviderConfigSchema = z.object({
209
221
  headers: z.record(z.string(), z.string()).optional(),
210
222
  /** 请求超时毫秒数(默认 10000) */
211
223
  timeout: z.number().int().min(0).default(1e4),
224
+ /** HTTP 重定向策略;默认拒绝。敏感发送始终拒绝,即使普通发送显式允许。 */
225
+ redirect: z.enum(["error", "follow"]).default("error"),
226
+ /** 强制 HTTPS;省略时生产敏感发送要求 HTTPS,显式 false 仅用于受信内网传输。 */
227
+ requireHttps: z.boolean().optional(),
212
228
  /** 接收端按 Idempotency-Key 原子持久去重,且覆盖所有重试与旧节点迟到请求时才启用;默认 false。 */
213
229
  idempotent: z.boolean().default(false)
214
230
  });
@@ -387,15 +403,51 @@ function getSafeEndpoint(url) {
387
403
  return "[invalid-url]";
388
404
  }
389
405
  }
390
- function toReachError2(error) {
406
+ function toReachError2() {
391
407
  return {
392
408
  code: HaiReachError.SEND_FAILED.code,
393
409
  message: reachM("reach_apiSendFailed", {
394
- params: { error: error instanceof Error ? error.message : String(error) }
395
- }),
396
- cause: error
410
+ params: { error: reachM("reach_apiRequestFailed") }
411
+ })
397
412
  };
398
413
  }
414
+ async function readAcknowledgement(response) {
415
+ const reader = response.body?.getReader();
416
+ let complete = false;
417
+ try {
418
+ let text = "";
419
+ let bytes = 0;
420
+ const decoder = new TextDecoder("utf-8", { fatal: true });
421
+ if (reader) {
422
+ while (true) {
423
+ const chunk = await reader.read();
424
+ if (chunk.done)
425
+ break;
426
+ bytes += chunk.value.byteLength;
427
+ if (bytes > 65536)
428
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseTooLarge"));
429
+ text += decoder.decode(chunk.value, { stream: true });
430
+ }
431
+ text += decoder.decode();
432
+ }
433
+ complete = true;
434
+ if (text.length === 0)
435
+ return ok({ success: true });
436
+ const result = JSON.parse(text);
437
+ if (!result || typeof result !== "object" || Array.isArray(result))
438
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
439
+ const messageId = "messageId" in result ? result.messageId : void 0;
440
+ if (messageId !== void 0 && (typeof messageId !== "string" || messageId.length > 255))
441
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
442
+ return ok({ success: true, ...messageId === void 0 ? {} : { messageId } });
443
+ } catch {
444
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
445
+ } finally {
446
+ if (!complete)
447
+ await reader?.cancel().catch(() => void 0);
448
+ reader?.releaseLock();
449
+ }
450
+ }
399
451
  function createApiProvider() {
400
452
  let apiConfig = null;
401
453
  return {
@@ -411,6 +463,10 @@ function createApiProvider() {
411
463
  );
412
464
  }
413
465
  apiConfig = config;
466
+ if (config.requireHttps && new URL(config.url).protocol !== "https:" || config.requireHttps && config.redirect === "follow") {
467
+ apiConfig = null;
468
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
469
+ }
414
470
  logger2.info("API provider connected", { endpoint: getSafeEndpoint(config.url), method: config.method });
415
471
  return ok(void 0);
416
472
  },
@@ -429,6 +485,9 @@ function createApiProvider() {
429
485
  );
430
486
  }
431
487
  logger2.debug("Sending via API callback", { endpoint: getSafeEndpoint(apiConfig.url) });
488
+ const requireHttps = apiConfig.requireHttps ?? (message.sensitive === true && core.module.requiresSharedState(process.env));
489
+ if (requireHttps && new URL(apiConfig.url).protocol !== "https:")
490
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
432
491
  try {
433
492
  const payload = {
434
493
  to: message.to,
@@ -450,25 +509,28 @@ function createApiProvider() {
450
509
  method: apiConfig.method,
451
510
  headers,
452
511
  body: JSON.stringify(payload),
453
- signal: controller.signal
512
+ signal: controller.signal,
513
+ redirect: message.sensitive || apiConfig.redirect !== "follow" ? "manual" : "follow"
454
514
  });
455
515
  if (!response.ok) {
516
+ await response.body?.cancel();
456
517
  logger2.warn("API callback returned non-OK status", { status: response.status });
457
- const rejected = response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
518
+ const rejected = response.status >= 300 && response.status < 400 || response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
458
519
  return err(
459
520
  rejected ? HaiReachError.DELIVERY_REJECTED : HaiReachError.SEND_FAILED,
460
521
  reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}` } })
461
522
  );
462
523
  }
463
- const result = await response.json();
464
- logger2.info("API callback sent", { messageId: result.messageId });
465
- return ok({ success: true, messageId: result.messageId });
524
+ const result = await readAcknowledgement(response);
525
+ if (result.success)
526
+ logger2.info("API callback accepted", { hasMessageId: result.data.messageId !== void 0 });
527
+ return result;
466
528
  } finally {
467
529
  clearTimeout(timeoutId);
468
530
  }
469
- } catch (error) {
470
- logger2.error("API callback send failed", { error });
471
- return err(toReachError2(error));
531
+ } catch {
532
+ logger2.error("API callback send failed", { errorCode: HaiReachError.SEND_FAILED.code });
533
+ return err(toReachError2());
472
534
  }
473
535
  }
474
536
  };
@@ -889,7 +951,12 @@ async function flushPendingMessages(providers2, repo) {
889
951
  }
890
952
  }
891
953
  try {
892
- const eligibleBefore = Date.now();
954
+ const clock = await repo.currentTime();
955
+ if (!clock.success) {
956
+ logger6.warn("Failed to read queue clock", { errorCode: clock.error.code });
957
+ return;
958
+ }
959
+ const eligibleBefore = clock.data;
893
960
  for (let processed = 0; processed < FLUSH_BATCH_SIZE; processed += 1) {
894
961
  const claimResult = await repo.claimPendingBatch(flushOwner, {
895
962
  limit: 1,
@@ -1204,9 +1271,11 @@ function migrateReach(options = {}, db = reldb) {
1204
1271
  var TABLE_NAME = "hai_reach_send_log";
1205
1272
  var DEFAULT_CLAIM_BATCH_SIZE = 100;
1206
1273
  var DEFAULT_CLAIM_LOCK_MS = 10 * 60 * 1e3;
1207
- var CLAIMABLE_STATUS_WHERE = "(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ?)) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ?)))";
1208
- function claimableParams(now, eligibleBefore = now) {
1209
- return ["pending", eligibleBefore, "processing", eligibleBefore, now, "sending", eligibleBefore, now];
1274
+ function claimableWhere(clock) {
1275
+ return `(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ${clock})) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ${clock})))`;
1276
+ }
1277
+ function claimableParams(eligibleBefore) {
1278
+ return ["pending", eligibleBefore, "processing", eligibleBefore, "sending", eligibleBefore];
1210
1279
  }
1211
1280
  function buildSendLogError(detail, cause) {
1212
1281
  return err(
@@ -1417,6 +1486,18 @@ async function createSendLogRepository(db) {
1417
1486
  return ok(repo);
1418
1487
  }
1419
1488
  var DbSendLogRepository = class extends BaseReldbCrudRepository {
1489
+ /** 共享后端取真正执行时钟;本地 SQLite 保留可控的单进程测试时钟。 */
1490
+ clockSql(localNow) {
1491
+ if (this.db.config?.type === "postgresql")
1492
+ return "CAST(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) AS BIGINT)";
1493
+ if (this.db.config?.type === "mysql")
1494
+ return "CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED)";
1495
+ return String(localNow ?? Date.now());
1496
+ }
1497
+ async currentTime() {
1498
+ const time = await this.sql().get(`SELECT ${this.clockSql()} AS now`);
1499
+ return time.success && time.data && Number.isSafeInteger(Number(time.data.now)) ? ok(Number(time.data.now)) : buildSendLogError(reachM("reach_claimLost"));
1500
+ }
1420
1501
  async settleSensitive(queueId, status, messageId) {
1421
1502
  const settled = await this.sql().execute(
1422
1503
  `UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, locked_until = NULL, updated_at = ? WHERE queue_id = ? AND status = 'sending' AND body IS NULL AND vars_json IS NULL AND retry_safe = FALSE`,
@@ -1476,15 +1557,15 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1476
1557
  * - 并发事务会直接跳过这些行,而不是重复命中同一批记录
1477
1558
  * - 在 commit 前不会暴露给其它节点重复 claim
1478
1559
  */
1479
- async claimPendingIdsWithRowLocks(tx, owner, now, lockedUntil, limit, eligibleBefore) {
1560
+ async claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) {
1480
1561
  const lockedRowsResult = await tx.query(
1481
1562
  `SELECT id
1482
1563
  FROM ${TABLE_NAME}
1483
- WHERE ${CLAIMABLE_STATUS_WHERE}
1564
+ WHERE ${claimableWhere(clock)}
1484
1565
  ORDER BY created_at ASC
1485
1566
  LIMIT ?
1486
1567
  FOR UPDATE SKIP LOCKED`,
1487
- [...claimableParams(now, eligibleBefore), limit]
1568
+ [...claimableParams(eligibleBefore), limit]
1488
1569
  );
1489
1570
  if (!lockedRowsResult.success) {
1490
1571
  throw lockedRowsResult.error;
@@ -1502,9 +1583,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1502
1583
  const placeholders = claimedIds.map(() => "?").join(", ");
1503
1584
  const updateResult = await tx.execute(
1504
1585
  `UPDATE ${TABLE_NAME}
1505
- SET status = ?, processing_owner = ?, locked_until = ?, updated_at = ?, attempt_count = attempt_count + 1
1586
+ SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
1506
1587
  WHERE id IN (${placeholders})`,
1507
- ["processing", owner, lockedUntil, now, ...claimedIds]
1588
+ ["processing", owner, lockMs, ...claimedIds]
1508
1589
  );
1509
1590
  if (!updateResult.success) {
1510
1591
  throw updateResult.error;
@@ -1518,10 +1599,10 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1518
1599
  * 即使多个节点读到了同一候选,只有第一个满足条件的 UPDATE 会成功,
1519
1600
  * 从而避免同一行被两个节点同时 claim。
1520
1601
  */
1521
- async claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit, eligibleBefore) {
1602
+ async claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore) {
1522
1603
  const candidatesResult = await this.findAll({
1523
- where: CLAIMABLE_STATUS_WHERE,
1524
- params: claimableParams(now, eligibleBefore),
1604
+ where: claimableWhere(clock),
1605
+ params: claimableParams(eligibleBefore),
1525
1606
  orderBy: "created_at ASC",
1526
1607
  limit
1527
1608
  }, tx);
@@ -1532,9 +1613,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1532
1613
  for (const row of candidatesResult.data) {
1533
1614
  const updateResult = await tx.execute(
1534
1615
  `UPDATE ${TABLE_NAME}
1535
- SET status = ?, processing_owner = ?, locked_until = ?, updated_at = ?, attempt_count = attempt_count + 1
1536
- WHERE id = ? AND ${CLAIMABLE_STATUS_WHERE}`,
1537
- ["processing", owner, lockedUntil, now, row.id, ...claimableParams(now, eligibleBefore)]
1616
+ SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
1617
+ WHERE id = ? AND ${claimableWhere(clock)}`,
1618
+ ["processing", owner, lockMs, row.id, ...claimableParams(eligibleBefore)]
1538
1619
  );
1539
1620
  if (!updateResult.success) {
1540
1621
  throw updateResult.error;
@@ -1560,10 +1641,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1560
1641
  }
1561
1642
  /** claim 一批待发送记录,避免多节点重复发送 */
1562
1643
  async claimPendingBatch(owner, options) {
1563
- const now = options?.now ?? Date.now();
1644
+ const time = await this.currentTime();
1645
+ if (!time.success)
1646
+ return time;
1647
+ const now = this.db.config?.type === "sqlite" ? options?.now ?? time.data : time.data;
1648
+ const clock = this.clockSql(now);
1564
1649
  const limit = options?.limit ?? DEFAULT_CLAIM_BATCH_SIZE;
1565
1650
  const lockMs = options?.lockMs ?? DEFAULT_CLAIM_LOCK_MS;
1566
- const lockedUntil = now + lockMs;
1567
1651
  const eligibleBefore = Math.min(now, options?.eligibleBefore ?? now);
1568
1652
  const dbType = this.db.config?.type;
1569
1653
  const txResult = await this.db.tx.begin();
@@ -1573,23 +1657,23 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1573
1657
  const tx = txResult.data;
1574
1658
  try {
1575
1659
  const unknown = await tx.execute(
1576
- `UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
1577
- WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <= ?)`,
1578
- ["unknown", now, "sending", now]
1660
+ `UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
1661
+ WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <= ${clock})`,
1662
+ ["unknown", "sending"]
1579
1663
  );
1580
1664
  if (!unknown.success)
1581
1665
  throw unknown.error;
1582
1666
  const exhausted = await tx.execute(
1583
1667
  `UPDATE ${TABLE_NAME}
1584
- SET status = ?, last_failure = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
1668
+ SET status = ?, last_failure = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
1585
1669
  WHERE attempt_count >= max_attempts
1586
- AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <= ?))
1587
- OR (status = ? AND retry_safe = TRUE AND locked_until <= ?))`,
1588
- ["dead", "attempts_exhausted", now, "pending", "processing", now, "sending", now]
1670
+ AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <= ${clock}))
1671
+ OR (status = ? AND retry_safe = TRUE AND locked_until <= ${clock}))`,
1672
+ ["dead", "attempts_exhausted", "pending", "processing", "sending"]
1589
1673
  );
1590
1674
  if (!exhausted.success)
1591
1675
  throw exhausted.error;
1592
- const claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner, now, lockedUntil, limit, eligibleBefore) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit, eligibleBefore);
1676
+ const claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore);
1593
1677
  if (claimedIds.length === 0) {
1594
1678
  const commitResult2 = await tx.commit();
1595
1679
  if (!commitResult2.success) {
@@ -1617,15 +1701,15 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1617
1701
  }
1618
1702
  /** 失败提交与 owner、次数和有效租约同时比较,旧 worker 不能更改新领取的退避策略。 */
1619
1703
  async failClaim(row, owner, reason, permanent = false, tx) {
1620
- const now = Date.now();
1704
+ const clock = this.clockSql();
1621
1705
  const dead = permanent || row.attemptCount >= row.maxAttempts;
1622
1706
  const delay = Math.min(row.maxRetryDelayMs, row.retryDelayMs * 2 ** Math.min(row.attemptCount - 1, 30));
1623
1707
  const result = await this.sql(tx).execute(
1624
1708
  `UPDATE ${TABLE_NAME}
1625
- SET status = ?, last_failure = ?, next_attempt_at = ?, processing_owner = NULL, locked_until = NULL, updated_at = ?
1626
- WHERE id = ? AND processing_owner = ? AND locked_until > ? AND attempt_count = ?
1709
+ SET status = ?, last_failure = ?, next_attempt_at = ${dead ? "0" : `${clock} + ?`}, processing_owner = NULL, locked_until = NULL, updated_at = ${clock}
1710
+ WHERE id = ? AND processing_owner = ? AND locked_until > ${clock} AND attempt_count = ?
1627
1711
  AND (status = ? OR (status = ? AND retry_safe = TRUE))`,
1628
- [dead ? "dead" : "pending", reason, dead ? 0 : now + delay, now, row.id, owner, now, row.attemptCount, "processing", "sending"]
1712
+ [dead ? "dead" : "pending", reason, ...dead ? [] : [delay], row.id, owner, row.attemptCount, "processing", "sending"]
1629
1713
  );
1630
1714
  if (!result.success) {
1631
1715
  return buildSendLogError(result.error.message, result.error);
@@ -1637,60 +1721,60 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
1637
1721
  }
1638
1722
  /** 仅当前 dead 版本可以恢复;保留 queueId,迟到的幂等 API 请求仍使用同一键。 */
1639
1723
  async requeueDead(queueId, expectedUpdatedAt) {
1640
- const now = Math.max(Date.now(), expectedUpdatedAt + 1);
1724
+ const clock = this.clockSql();
1641
1725
  const result = await this.sql().execute(
1642
- `UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at = ?, last_failure = NULL, updated_at = ?
1726
+ `UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at = ${clock}, last_failure = NULL, updated_at = CASE WHEN updated_at >= ${clock} THEN updated_at + 1 ELSE ${clock} END
1643
1727
  WHERE queue_id = ? AND status = ? AND updated_at = ?`,
1644
- ["pending", now, now, queueId, "dead", expectedUpdatedAt]
1728
+ ["pending", queueId, "dead", expectedUpdatedAt]
1645
1729
  );
1646
1730
  return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_deadRequeueFailed"));
1647
1731
  }
1648
1732
  /** 发送前持久化门闩;仅有效租约可从 processing 进入 sending。 */
1649
1733
  async beginDelivery(id, owner, retrySafe, lockMs = DEFAULT_CLAIM_LOCK_MS) {
1650
- const now = Date.now();
1734
+ const clock = this.clockSql();
1651
1735
  const flag = this.db.config?.type === "postgresql" ? retrySafe : Number(retrySafe);
1652
1736
  const result = await this.sql().execute(
1653
- `UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ?, updated_at = ?
1654
- WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ?`,
1655
- ["sending", flag, now + lockMs, now, id, "processing", owner, now]
1737
+ `UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ${clock} + ?, updated_at = ${clock}
1738
+ WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
1739
+ ["sending", flag, lockMs, id, "processing", owner]
1656
1740
  );
1657
1741
  return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
1658
1742
  }
1659
1743
  /** 续租不接受已经过期的 owner,防止暂停的旧进程恢复后重新取得发送权。 */
1660
1744
  async renewClaim(id, owner, lockMs = DEFAULT_CLAIM_LOCK_MS) {
1661
- const now = Date.now();
1745
+ const clock = this.clockSql();
1662
1746
  const result = await this.sql().execute(
1663
- `UPDATE ${TABLE_NAME} SET locked_until = ?, updated_at = ?
1664
- WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until > ?`,
1665
- [now + lockMs, now, id, owner, "processing", "sending", now]
1747
+ `UPDATE ${TABLE_NAME} SET locked_until = ${clock} + ?, updated_at = ${clock}
1748
+ WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until > ${clock}`,
1749
+ [lockMs, id, owner, "processing", "sending"]
1666
1750
  );
1667
1751
  return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
1668
1752
  }
1669
1753
  /** 未知结果只能停止自动处理,不推断外部副作用已撤销。 */
1670
1754
  async markUnknown(id, owner) {
1671
1755
  const result = await this.sql().execute(
1672
- `UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
1756
+ `UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${this.clockSql()}
1673
1757
  WHERE id = ? AND processing_owner = ? AND status IN (?, ?)`,
1674
- ["unknown", Date.now(), id, owner, "processing", "sending"]
1758
+ ["unknown", id, owner, "processing", "sending"]
1675
1759
  );
1676
1760
  return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
1677
1761
  }
1678
1762
  /** 人工裁定的条件提交,不提供重新排队或重新发送操作。 */
1679
1763
  async resolveUnknown(queueId, resolution) {
1680
1764
  const result = await this.sql().execute(
1681
- `UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at = ? WHERE queue_id = ? AND status = ?`,
1682
- [resolution.status, resolution.messageId ?? null, Date.now(), queueId, "unknown"]
1765
+ `UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at = ${this.clockSql()} WHERE queue_id = ? AND status = ?`,
1766
+ [resolution.status, resolution.messageId ?? null, queueId, "unknown"]
1683
1767
  );
1684
1768
  return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_unknownResolutionFailed"));
1685
1769
  }
1686
1770
  /** 只有仍持有 sending 有效租约的 owner 可以确认投递。 */
1687
1771
  async markSent(id, owner, messageId, tx) {
1688
- const now = Date.now();
1772
+ const clock = this.clockSql();
1689
1773
  const result = await this.sql(tx).execute(
1690
1774
  `UPDATE ${TABLE_NAME}
1691
- SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
1692
- WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ?`,
1693
- ["sent", messageId ?? null, now, id, "sending", owner, now]
1775
+ SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
1776
+ WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
1777
+ ["sent", messageId ?? null, id, "sending", owner]
1694
1778
  );
1695
1779
  if (!result.success) {
1696
1780
  return buildSendLogError(result.error.message, result.error);