@rei-standard/amsg-server 2.6.0-next.13 → 2.6.0-next.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/d1.d.ts +106 -0
- package/dist/adapters/interface.d.ts +50 -0
- package/dist/adapters/neon.d.ts +10 -58
- package/dist/adapters/pg-shared.d.ts +114 -0
- package/dist/adapters/pg.d.ts +10 -58
- package/dist/adapters/schema.d.ts +1 -1
- package/dist/adapters/schema.sqlite.d.ts +3 -1
- package/dist/{chunk-B2FDV7YK.mjs → chunk-34I2YSWE.mjs} +10 -0
- package/dist/{chunk-TV7N3RTH.mjs → chunk-D3DQF647.mjs} +1086 -348
- package/dist/{chunk-V2SDWGUB.cjs → chunk-OUPM4HAV.cjs} +10 -0
- package/dist/{chunk-5IOYCCFD.cjs → chunk-TCKPLM3P.cjs} +1086 -348
- package/dist/chunk-TFFBMYA2.cjs +95 -0
- package/dist/chunk-UYROU4L3.mjs +95 -0
- package/dist/cloudflare/single-user-worker.d.ts +2 -0
- package/dist/cloudflare.cjs +3 -2
- package/dist/cloudflare.mjs +2 -1
- package/dist/handlers/outbox.d.ts +7 -0
- package/dist/index.cjs +64 -36
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.mjs +45 -17
- package/dist/lib/client-state-store.d.ts +18 -3
- package/dist/lib/errors.d.ts +50 -0
- package/dist/lib/message-processor.d.ts +8 -2
- package/dist/lib/outbox-store.d.ts +33 -0
- package/dist/lib/push-subscription-store.d.ts +10 -2
- package/dist/lib/request.d.ts +36 -0
- package/dist/lib/run-tick.d.ts +28 -0
- package/dist/lib/validation.d.ts +1 -7
- package/dist/{neon-CUIGAB2T.mjs → neon-XLWK27RL.mjs} +29 -104
- package/dist/{neon-3ASBLLCM.cjs → neon-ZWA2U7IZ.cjs} +37 -112
- package/dist/{pg-IEZTAR43.cjs → pg-Q4VU6D75.cjs} +35 -112
- package/dist/{pg-KMJPL6QV.mjs → pg-X63HFEV7.mjs} +27 -104
- package/dist/single-user.d.ts +4 -0
- package/package.json +2 -2
|
@@ -12,6 +12,13 @@ export function stateValueBytes(value: any): number;
|
|
|
12
12
|
* 两者都受同一套 last-write-wins 约束:`updatedAt` 比库里已有值旧的写入
|
|
13
13
|
* (或删除)不生效,陈旧批次盖不掉新数据。
|
|
14
14
|
*
|
|
15
|
+
* 条件写护栏:条目可带可选的 `version`(毫秒时间戳或单调递增整数)。带了它,
|
|
16
|
+
* 比较用的就是这个值而不是 `updatedAt`——同一个 key 有多个写入方时(例如
|
|
17
|
+
* fire_pack 由常规 flush 和 instant-chat 两条路径写),谁的**内容**新谁赢,
|
|
18
|
+
* 而不是谁的请求后到谁赢:慢网下晚到的旧包带着旧 `version`,盖不掉先到的新
|
|
19
|
+
* 包。护栏值落在行的 updated_at 列上(client_state 的比较列本来就是它),
|
|
20
|
+
* 没带 `version` 的写入照旧按 `updatedAt` 比。
|
|
21
|
+
*
|
|
15
22
|
* 条目本身的合法性(namespace / key 字符、value 大小)由调用方先校验好:
|
|
16
23
|
* HTTP handler 逐条拒绝并把原因回给客户端,`writeState()` 直接抛错给 hook。
|
|
17
24
|
*
|
|
@@ -19,10 +26,13 @@ export function stateValueBytes(value: any): number;
|
|
|
19
26
|
* @param {{ upsertClientState: Function }} args.db
|
|
20
27
|
* @param {string} args.userId
|
|
21
28
|
* @param {CryptoKey|string} args.userKey - 该用户的存储密钥(value 用它加密)
|
|
22
|
-
* @param {Array<{ namespace: string, key: string, value: string|null, updatedAt: number }>} args.entries
|
|
23
|
-
*
|
|
29
|
+
* @param {Array<{ namespace: string, key: string, value: string|null, updatedAt: number, version?: number }>} args.entries
|
|
30
|
+
* `version`(可选):条件写护栏值,见文件头。带了它,这条的 last-write-wins
|
|
31
|
+
* 比较用它(写进行的 updated_at 列);没带照旧用 `updatedAt`。
|
|
32
|
+
* @returns {Promise<{ upserted: number, skipped: number, deleted: number, skippedEntries: Array<{ namespace: string, key: string }> }>}
|
|
24
33
|
* `upserted` / `skipped` 按逻辑条目计(切片行不计);`deleted` 是请求删除的
|
|
25
|
-
* key 数,不代表这些 key
|
|
34
|
+
* key 数,不代表这些 key 原本一定存在。`skippedEntries` 逐条列出被
|
|
35
|
+
* last-write-wins 拦下的 key(适配器不回 outcomes 时为空数组——分不清是哪条)。
|
|
26
36
|
*/
|
|
27
37
|
export function writeClientStateEntries({ db, userId, userKey, entries }: {
|
|
28
38
|
db: {
|
|
@@ -35,11 +45,16 @@ export function writeClientStateEntries({ db, userId, userKey, entries }: {
|
|
|
35
45
|
key: string;
|
|
36
46
|
value: string | null;
|
|
37
47
|
updatedAt: number;
|
|
48
|
+
version?: number;
|
|
38
49
|
}>;
|
|
39
50
|
}): Promise<{
|
|
40
51
|
upserted: number;
|
|
41
52
|
skipped: number;
|
|
42
53
|
deleted: number;
|
|
54
|
+
skippedEntries: Array<{
|
|
55
|
+
namespace: string;
|
|
56
|
+
key: string;
|
|
57
|
+
}>;
|
|
43
58
|
}>;
|
|
44
59
|
export const MAX_STATE_ENTRIES_PER_BATCH: 200;
|
|
45
60
|
export const MAX_NAMESPACE_CHARS: 128;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 这个错误是不是「重试也好不了」。认 NonRetryableError,也认任何自带
|
|
3
|
+
* `permanent: true` 的错误对象(跨包/跨版本安全)。
|
|
4
|
+
*
|
|
5
|
+
* @param {unknown} error
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isNonRetryableError(error: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* 把错误原因脱敏成能明文落库(任务行 last_error 列)的摘要。
|
|
11
|
+
*
|
|
12
|
+
* 任务内容一律密文落库,last_error 是唯一一列明文的「为什么没发出去」——
|
|
13
|
+
* 错误消息偶尔会回显请求细节(上游 API 的报错带 URL、header 片段),所以
|
|
14
|
+
* 长得像凭据的 token 一律遮掉,再截断到 500 字符。
|
|
15
|
+
*
|
|
16
|
+
* @param {unknown} reason
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeErrorSummary(reason: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* 错误类型:宿主 hook 与库内部共用的失败语义标注。
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* 确定性失败——重试必然同败,别再排退避阶梯。
|
|
25
|
+
*
|
|
26
|
+
* 典型场景全在 fire-time hook 里:onBeforeFire 发现 fire_pack 缺失、解析失
|
|
27
|
+
* 败、缺必要的段……这类错误不会因为过两分钟再跑一次就好,按普通投递失败重
|
|
28
|
+
* 试三轮只是让用户多白等十几分钟,还把 hook 里的计费调用(情绪评估之类)
|
|
29
|
+
* 重复烧三遍。hook 抛这个类(或任何带 `permanent: true` 的错误),run-tick
|
|
30
|
+
* 收到后跳过退避:一次性任务直接标 failed(原因进 last_error / payload 的
|
|
31
|
+
* lastError),循环任务直接作废本次 occurrence。
|
|
32
|
+
*
|
|
33
|
+
* `code`(可选)会透传到 processSingleMessage 的 errorCode,宿主想按错误类
|
|
34
|
+
* 别分流时用它。
|
|
35
|
+
*
|
|
36
|
+
* 用 `permanent` 属性而不是 instanceof 做判定:宿主的 worker 里可能打包了另
|
|
37
|
+
* 一份本库(版本错开、bundler 双实例),跨 realm 的 instanceof 靠不住。
|
|
38
|
+
*/
|
|
39
|
+
export class NonRetryableError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} message
|
|
42
|
+
* @param {{ code?: string, cause?: unknown }} [options]
|
|
43
|
+
*/
|
|
44
|
+
constructor(message: string, options?: {
|
|
45
|
+
code?: string;
|
|
46
|
+
cause?: unknown;
|
|
47
|
+
});
|
|
48
|
+
permanent: boolean;
|
|
49
|
+
code: string;
|
|
50
|
+
}
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
* @param {import('../adapters/interface.js').TaskRow} task
|
|
11
11
|
* @param {ProcessorContext} ctx
|
|
12
12
|
* @param {string} [providedMasterKey]
|
|
13
|
-
* @
|
|
13
|
+
* @param {{ userKey: string, payload: Object } | null} [predecrypted] - 调用方
|
|
14
|
+
* (run-tick 的预扫描)已经解好的 payload;传了就不再解第二遍。
|
|
15
|
+
* @returns {Promise<{ success: boolean, messagesSent: number, error?: string, errorCode?: string|null }>}
|
|
14
16
|
*/
|
|
15
|
-
export function processSingleMessage(task: import("../adapters/interface.js").TaskRow, ctx: ProcessorContext, providedMasterKey?: string
|
|
17
|
+
export function processSingleMessage(task: import("../adapters/interface.js").TaskRow, ctx: ProcessorContext, providedMasterKey?: string, predecrypted?: {
|
|
18
|
+
userKey: string;
|
|
19
|
+
payload: any;
|
|
20
|
+
} | null): Promise<{
|
|
16
21
|
success: boolean;
|
|
17
22
|
messagesSent: number;
|
|
18
23
|
error?: string;
|
|
24
|
+
errorCode?: string | null;
|
|
19
25
|
}>;
|
|
20
26
|
/**
|
|
21
27
|
* Process a single message identified by UUID (used for instant type).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** 适配器支持 outbox 吗(写入侧要用的两个方法都在才算)。 */
|
|
2
|
+
export function supportsOutbox(db: any): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* 发送前把这一批 push 落进 outbox。push 对象须已定稿(messageId / sessionId /
|
|
5
|
+
* messageIndex / totalMessages / 任务身份都已补齐)——落进去的密文就是客户端
|
|
6
|
+
* 补收时拿到的那一份。
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} args
|
|
9
|
+
* @param {Object} args.db
|
|
10
|
+
* @param {string} args.userId
|
|
11
|
+
* @param {CryptoKey|string} args.userKey - per-user 存储密钥
|
|
12
|
+
* @param {Object[]} args.pushes - 定稿后的 push 对象
|
|
13
|
+
* @returns {Promise<boolean>} 是否真的落了行(适配器不支持 / 落行失败 → false)
|
|
14
|
+
*/
|
|
15
|
+
export function appendPushesToOutbox({ db, userId, userKey, pushes }: {
|
|
16
|
+
db: any;
|
|
17
|
+
userId: string;
|
|
18
|
+
userKey: CryptoKey | string;
|
|
19
|
+
pushes: any[];
|
|
20
|
+
}): Promise<boolean>;
|
|
21
|
+
/**
|
|
22
|
+
* 把发出去的那部分标成 delivered(发送半途失败时只标已发出的段)。
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} args
|
|
25
|
+
* @param {Object} args.db
|
|
26
|
+
* @param {string} args.userId
|
|
27
|
+
* @param {string[]} args.messageIds
|
|
28
|
+
*/
|
|
29
|
+
export function markPushesDelivered({ db, userId, messageIds }: {
|
|
30
|
+
db: any;
|
|
31
|
+
userId: string;
|
|
32
|
+
messageIds: string[];
|
|
33
|
+
}): Promise<void>;
|
|
@@ -48,16 +48,24 @@ export function loadPushSubscription({ db, userId, userKey }: {
|
|
|
48
48
|
/**
|
|
49
49
|
* 投递前取订阅。取不到就抛——静默不发会让任务「成功」地什么都没做,用户
|
|
50
50
|
* 只看到消息凭空消失。抛出去走既有的重试 / 标记逻辑,原因也会记进 payload
|
|
51
|
-
* 的 lastError,`GET /messages`
|
|
51
|
+
* 的 lastError,`GET /messages` 上看得见。抛出的错误带稳定的 `code` 属性
|
|
52
|
+
* ('PUSH_SUBSCRIPTION_MISSING' / 'PUSH_SUBSCRIPTION_STORE_UNSUPPORTED'),
|
|
53
|
+
* 按类别分支请用它,别匹配 message 文案。
|
|
54
|
+
*
|
|
55
|
+
* `legacyFallback`:用户级存储里没有订阅时的兜底(升级前创建的任务把订阅
|
|
56
|
+
* 冻结在自己的 payload 里,这份订阅仍然有效)。存储里有订阅时永远用存储的
|
|
57
|
+
* 那份——它是用户最近一次登记的。
|
|
52
58
|
*
|
|
53
59
|
* @param {Object} args
|
|
54
60
|
* @param {import('../adapters/interface.js').DbAdapter} args.db
|
|
55
61
|
* @param {string} args.userId
|
|
56
62
|
* @param {string} args.userKey
|
|
63
|
+
* @param {unknown} [args.legacyFallback] - 旧任务 payload 里内嵌的订阅(可选)
|
|
57
64
|
* @returns {Promise<Object>} 明文订阅对象
|
|
58
65
|
*/
|
|
59
|
-
export function resolvePushSubscription({ db, userId, userKey }: {
|
|
66
|
+
export function resolvePushSubscription({ db, userId, userKey, legacyFallback }: {
|
|
60
67
|
db: import("../adapters/interface.js").DbAdapter;
|
|
61
68
|
userId: string;
|
|
62
69
|
userKey: string;
|
|
70
|
+
legacyFallback?: unknown;
|
|
63
71
|
}): Promise<any>;
|
package/dist/lib/request.d.ts
CHANGED
|
@@ -56,6 +56,42 @@ export function isEncryptedEnvelope(payload: unknown): payload is {
|
|
|
56
56
|
* @returns {ParseBodyResult}
|
|
57
57
|
*/
|
|
58
58
|
export function parseEncryptedBody(body: unknown): ParseBodyResult;
|
|
59
|
+
/**
|
|
60
|
+
* 标准错误信封:{ status, body: { success: false, error: { code, message, details? } } }。
|
|
61
|
+
*
|
|
62
|
+
* @param {number} status
|
|
63
|
+
* @param {string} code
|
|
64
|
+
* @param {string} message
|
|
65
|
+
* @param {Object} [details]
|
|
66
|
+
*/
|
|
67
|
+
export function errorResponse(status: number, code: string, message: string, details?: any): {
|
|
68
|
+
status: number;
|
|
69
|
+
body: {
|
|
70
|
+
success: boolean;
|
|
71
|
+
error: {
|
|
72
|
+
code: string;
|
|
73
|
+
message: string;
|
|
74
|
+
details?: undefined;
|
|
75
|
+
} | {
|
|
76
|
+
code: string;
|
|
77
|
+
message: string;
|
|
78
|
+
details: any;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* X-User-Id 门禁。所有按用户读写的端点共用这一份:规则(必填 + UUID v4)和
|
|
84
|
+
* 文案只此一处,改口径不用挨个 handler 找复制粘贴的副本。
|
|
85
|
+
*
|
|
86
|
+
* @param {Record<string, any>} headers
|
|
87
|
+
* @returns {{ userId: string, error?: undefined } | { error: ReturnType<typeof errorResponse> }}
|
|
88
|
+
*/
|
|
89
|
+
export function requireUserId(headers: Record<string, any>): {
|
|
90
|
+
userId: string;
|
|
91
|
+
error?: undefined;
|
|
92
|
+
} | {
|
|
93
|
+
error: ReturnType<typeof errorResponse>;
|
|
94
|
+
};
|
|
59
95
|
/**
|
|
60
96
|
* Read a header value case-insensitively.
|
|
61
97
|
*
|
package/dist/lib/run-tick.d.ts
CHANGED
|
@@ -27,8 +27,35 @@ export function runScheduledTick(ctx: any): Promise<{
|
|
|
27
27
|
failedTasks: any[];
|
|
28
28
|
};
|
|
29
29
|
}>;
|
|
30
|
+
/**
|
|
31
|
+
* 只跑一条任务的官方入口,给「fetch 里只来得及 enqueue、真正的 fire 交给
|
|
32
|
+
* CF Queue 消费者(15 分钟预算)跑」这类宿主用——不用再依赖 cron 恰好捞到。
|
|
33
|
+
*
|
|
34
|
+
* 与 cron tick 走完全同一条投递链:占位(含心跳续租)、过期守卫、分组串行
|
|
35
|
+
* (单任务场景下退化为占位时的跨 tick 分组门)、失败重试/终态、hook 全套。
|
|
36
|
+
* 行没到点(next_send_at 在未来)或正处在退避窗口(retry_after 未到点)时不
|
|
37
|
+
* 跑——这个入口只是换了个触发器,不是绕过排期的后门。
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} ctx - 与 runScheduledTick 同一份 ctx
|
|
40
|
+
* @param {string} uuid - 任务 uuid(pending 行)
|
|
41
|
+
* @returns {Promise<{ ran: false, reason: 'not_found'|'not_due'|'retry_pending', nextSendAt?: string, retryAfter?: string }
|
|
42
|
+
* | { ran: true, summary: Object }>} summary 与 runScheduledTick 的返回同构
|
|
43
|
+
* (totalTasks 恒为 1);任务被别的执行者占着时体现在 summary.details.claimSkippedTasks。
|
|
44
|
+
*/
|
|
45
|
+
export function runTask(ctx: any, uuid: string): Promise<{
|
|
46
|
+
ran: false;
|
|
47
|
+
reason: "not_found" | "not_due" | "retry_pending";
|
|
48
|
+
nextSendAt?: string;
|
|
49
|
+
retryAfter?: string;
|
|
50
|
+
} | {
|
|
51
|
+
ran: true;
|
|
52
|
+
summary: any;
|
|
53
|
+
}>;
|
|
30
54
|
export const DEFAULT_CLAIM_LEASE_MS: number;
|
|
55
|
+
export const DEFAULT_LEASE_HEARTBEAT_MS: number;
|
|
56
|
+
export const DEFAULT_HEARTBEAT_LEASE_TTL_MS: number;
|
|
31
57
|
export const STALE_AFTER_MS: number;
|
|
58
|
+
export { sanitizeErrorSummary };
|
|
32
59
|
export type StaleSkipInfo = {
|
|
33
60
|
reason: "stale";
|
|
34
61
|
/**
|
|
@@ -66,3 +93,4 @@ export type StaleSkipInfo = {
|
|
|
66
93
|
readState: (namespace: string) => Promise<any[]>;
|
|
67
94
|
writeState: (namespace: string, entries: any[]) => Promise<any>;
|
|
68
95
|
};
|
|
96
|
+
import { sanitizeErrorSummary } from './errors.js';
|
package/dist/lib/validation.d.ts
CHANGED
|
@@ -4,12 +4,6 @@
|
|
|
4
4
|
* @returns {boolean}
|
|
5
5
|
*/
|
|
6
6
|
export function isValidISO8601(dateString: string): boolean;
|
|
7
|
-
/**
|
|
8
|
-
* Validate URL format.
|
|
9
|
-
* @param {string} urlString
|
|
10
|
-
* @returns {boolean}
|
|
11
|
-
*/
|
|
12
|
-
export function isValidUrl(urlString: string): boolean;
|
|
13
7
|
/**
|
|
14
8
|
* Validate UUID format.
|
|
15
9
|
* @param {string} uuid
|
|
@@ -65,4 +59,4 @@ export function validateScheduleMessagePayload(payload: any): {
|
|
|
65
59
|
details?: any;
|
|
66
60
|
};
|
|
67
61
|
import { isValidTimeZoneId } from './recurrence.js';
|
|
68
|
-
export { isValidTimeZoneId, validateAvatarUrl };
|
|
62
|
+
export { isValidTimeZoneId, isValidUrl, validateAvatarUrl };
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claimTask,
|
|
3
|
+
deletePushSubscription,
|
|
4
|
+
getPushSubscription,
|
|
5
|
+
getTaskStatusInfo,
|
|
6
|
+
renewTaskLease,
|
|
7
|
+
upsertPushSubscription
|
|
8
|
+
} from "./chunk-UYROU4L3.mjs";
|
|
1
9
|
import {
|
|
2
10
|
COLUMNS_SQL,
|
|
3
11
|
INDEXES,
|
|
@@ -6,7 +14,7 @@ import {
|
|
|
6
14
|
TABLE_SQL,
|
|
7
15
|
UPDATABLE_COLUMNS,
|
|
8
16
|
VERIFY_TABLE_SQL
|
|
9
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-34I2YSWE.mjs";
|
|
10
18
|
|
|
11
19
|
// src/server/adapters/neon.js
|
|
12
20
|
import { neon } from "@neondatabase/serverless";
|
|
@@ -91,7 +99,7 @@ var NeonAdapter = class {
|
|
|
91
99
|
async getTaskByUuid(uuid, userId) {
|
|
92
100
|
const sql = this._getSql();
|
|
93
101
|
const rows = await sql.query(
|
|
94
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
|
|
102
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
|
|
95
103
|
FROM scheduled_messages
|
|
96
104
|
WHERE uuid = $1 AND user_id = $2 AND status = 'pending'
|
|
97
105
|
LIMIT 1`,
|
|
@@ -171,7 +179,7 @@ var NeonAdapter = class {
|
|
|
171
179
|
async getPendingTasks(limit = 50) {
|
|
172
180
|
const sql = this._getSql();
|
|
173
181
|
return sql.query(
|
|
174
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
182
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
|
|
175
183
|
FROM scheduled_messages
|
|
176
184
|
WHERE status = 'pending' AND next_send_at <= NOW()
|
|
177
185
|
AND (lease_until IS NULL OR lease_until <= NOW())
|
|
@@ -181,65 +189,16 @@ var NeonAdapter = class {
|
|
|
181
189
|
[limit]
|
|
182
190
|
);
|
|
183
191
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
* 本次投递期间别的 tick 领不走它。
|
|
187
|
-
*
|
|
188
|
-
* 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
|
|
189
|
-
* 任务列表要读它、循环任务推进下一次也要拿它当基准。
|
|
190
|
-
*
|
|
191
|
-
* 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
|
|
192
|
-
* 过。WHERE 里的两个条件各管一件事:
|
|
193
|
-
* - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
|
|
194
|
-
* 也不会把行焊死,租约到期后自然可以被接手。
|
|
195
|
-
* - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
|
|
196
|
-
* 这一跳就不该再按旧时刻发。
|
|
197
|
-
*
|
|
198
|
-
* 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
|
|
199
|
-
* 要改表。
|
|
200
|
-
*
|
|
201
|
-
* 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
|
|
202
|
-
* 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
|
|
203
|
-
*
|
|
204
|
-
* 带 serializeGroup 时多一道分组门:同一分组里已经有别的行拿着未到期的租
|
|
205
|
-
* 约,这条就领不走(同一分组同时只跑一条)。判定和写租约在同一条 UPDATE
|
|
206
|
-
* 里完成,「先查再占」的空档天然不存在。分组门只看租约,不看
|
|
207
|
-
* `retry_after`:等着重试的任务其实闲着,不该把同分组的其他任务一起堵住。
|
|
208
|
-
*
|
|
209
|
-
* @param {number} taskId
|
|
210
|
-
* @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
211
|
-
* @param {string|Date} leaseUntil - 租期末尾
|
|
212
|
-
* @param {string|null} [serializeGroup] - 串行分组标识;空表示不参与分组串行
|
|
213
|
-
* @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、同分组
|
|
214
|
-
* 有任务正在跑、排期被改过、或行已不是 pending
|
|
215
|
-
*/
|
|
192
|
+
// 领取一条到点的任务。SQL 与并发语义在 pg-shared.js(pg / neon 共用一份,
|
|
193
|
+
// 语义说明也在那里)。
|
|
216
194
|
async claimTask(taskId, expectedNextSendAt, leaseUntil, serializeGroup = null) {
|
|
217
195
|
const sql = this._getSql();
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
setClause = "lease_until = $1, serialize_group = $4, updated_at = NOW()";
|
|
225
|
-
groupGuard = `
|
|
226
|
-
AND NOT EXISTS (
|
|
227
|
-
SELECT 1 FROM scheduled_messages busy
|
|
228
|
-
WHERE busy.serialize_group = $4 AND busy.id <> $2
|
|
229
|
-
AND busy.status = 'pending' AND busy.lease_until > NOW()
|
|
230
|
-
)`;
|
|
231
|
-
}
|
|
232
|
-
const rows = await sql.query(
|
|
233
|
-
`UPDATE scheduled_messages
|
|
234
|
-
SET ${setClause}
|
|
235
|
-
WHERE id = $2 AND status = 'pending'
|
|
236
|
-
AND date_trunc('milliseconds', next_send_at)
|
|
237
|
-
= date_trunc('milliseconds', $3::timestamptz)
|
|
238
|
-
AND (lease_until IS NULL OR lease_until <= NOW())${groupGuard}
|
|
239
|
-
RETURNING id`,
|
|
240
|
-
params
|
|
241
|
-
);
|
|
242
|
-
return rows.length > 0;
|
|
196
|
+
return claimTask((text, params) => sql.query(text, params), taskId, expectedNextSendAt, leaseUntil, serializeGroup);
|
|
197
|
+
}
|
|
198
|
+
// 投递期间的租约续期(run-tick 心跳;语义见 pg-shared.js)。
|
|
199
|
+
async renewTaskLease(taskId, leaseUntil) {
|
|
200
|
+
const sql = this._getSql();
|
|
201
|
+
return renewTaskLease((text, params) => sql.query(text, params), taskId, leaseUntil);
|
|
243
202
|
}
|
|
244
203
|
async listTasks(userId, opts = {}) {
|
|
245
204
|
const sql = this._getSql();
|
|
@@ -260,7 +219,7 @@ var NeonAdapter = class {
|
|
|
260
219
|
const total = parseInt(countRows[0].count, 10);
|
|
261
220
|
const taskParams = [...params, limit, offset];
|
|
262
221
|
const tasks = await sql.query(
|
|
263
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
|
|
222
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
|
|
264
223
|
FROM scheduled_messages
|
|
265
224
|
WHERE ${where}
|
|
266
225
|
ORDER BY next_send_at ASC
|
|
@@ -289,57 +248,23 @@ var NeonAdapter = class {
|
|
|
289
248
|
);
|
|
290
249
|
return rows.length > 0 ? rows[0].status : null;
|
|
291
250
|
}
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
*/
|
|
251
|
+
// 状态 + 失败摘要(GET /message 对已失败的行透出原因;语义见 pg-shared.js)。
|
|
252
|
+
async getTaskStatusInfo(uuid, userId) {
|
|
253
|
+
const sql = this._getSql();
|
|
254
|
+
return getTaskStatusInfo((text, params) => sql.query(text, params), uuid, userId);
|
|
255
|
+
}
|
|
256
|
+
// ── push_subscriptions(实现见 pg-shared.js,pg / neon 共用一份)─────────
|
|
299
257
|
async getPushSubscription(userId) {
|
|
300
258
|
const sql = this._getSql();
|
|
301
|
-
|
|
302
|
-
"SELECT subscription, updated_at FROM push_subscriptions WHERE user_id = $1 LIMIT 1",
|
|
303
|
-
[userId]
|
|
304
|
-
);
|
|
305
|
-
if (rows.length === 0) return null;
|
|
306
|
-
return { subscription: rows[0].subscription, updated_at: Number(rows[0].updated_at) };
|
|
259
|
+
return getPushSubscription((text, params) => sql.query(text, params), userId);
|
|
307
260
|
}
|
|
308
|
-
/**
|
|
309
|
-
* 覆盖写这个用户的订阅。一个用户一行,没有 last-write-wins 之类的比较——
|
|
310
|
-
* 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
|
|
311
|
-
*
|
|
312
|
-
* @param {string} userId
|
|
313
|
-
* @param {string} encryptedSubscription
|
|
314
|
-
* @param {number} updatedAt - epoch 毫秒
|
|
315
|
-
* @returns {Promise<boolean>}
|
|
316
|
-
*/
|
|
317
261
|
async upsertPushSubscription(userId, encryptedSubscription, updatedAt) {
|
|
318
262
|
const sql = this._getSql();
|
|
319
|
-
|
|
320
|
-
`INSERT INTO push_subscriptions (user_id, subscription, updated_at)
|
|
321
|
-
VALUES ($1, $2, $3)
|
|
322
|
-
ON CONFLICT (user_id) DO UPDATE SET
|
|
323
|
-
subscription = EXCLUDED.subscription,
|
|
324
|
-
updated_at = EXCLUDED.updated_at
|
|
325
|
-
RETURNING user_id`,
|
|
326
|
-
[userId, encryptedSubscription, updatedAt]
|
|
327
|
-
);
|
|
328
|
-
return rows.length > 0;
|
|
263
|
+
return upsertPushSubscription((text, params) => sql.query(text, params), userId, encryptedSubscription, updatedAt);
|
|
329
264
|
}
|
|
330
|
-
/**
|
|
331
|
-
* 删掉这个用户的订阅(设置页「停止接收推送」)。
|
|
332
|
-
*
|
|
333
|
-
* @param {string} userId
|
|
334
|
-
* @returns {Promise<boolean>} true = 确实删掉了一行
|
|
335
|
-
*/
|
|
336
265
|
async deletePushSubscription(userId) {
|
|
337
266
|
const sql = this._getSql();
|
|
338
|
-
|
|
339
|
-
"DELETE FROM push_subscriptions WHERE user_id = $1 RETURNING user_id",
|
|
340
|
-
[userId]
|
|
341
|
-
);
|
|
342
|
-
return rows.length > 0;
|
|
267
|
+
return deletePushSubscription((text, params) => sql.query(text, params), userId);
|
|
343
268
|
}
|
|
344
269
|
};
|
|
345
270
|
export {
|