@aiao/rxdb-adapter-http 0.0.26
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/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/RxDBAdapterHttp.d.ts +251 -0
- package/dist/RxDBAdapterHttp.d.ts.map +1 -0
- package/dist/change-feed.d.ts +66 -0
- package/dist/change-feed.d.ts.map +1 -0
- package/dist/chunking.d.ts +29 -0
- package/dist/chunking.d.ts.map +1 -0
- package/dist/conditional-cache.d.ts +175 -0
- package/dist/conditional-cache.d.ts.map +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/errors.d.ts +215 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/handler-contract.d.ts +41 -0
- package/dist/handler-contract.d.ts.map +1 -0
- package/dist/http.interface.d.ts +519 -0
- package/dist/http.interface.d.ts.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +864 -0
- package/dist/metadata.d.ts +15 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/pagination.d.ts +30 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/rest.d.ts +94 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/transport.d.ts +174 -0
- package/dist/transport.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/RxDBAdapterHttp.ts +786 -0
- package/src/change-feed.ts +470 -0
- package/src/chunking.ts +68 -0
- package/src/conditional-cache.ts +271 -0
- package/src/config.ts +83 -0
- package/src/errors.ts +295 -0
- package/src/handler-contract.ts +77 -0
- package/src/http.interface.ts +554 -0
- package/src/index.ts +25 -0
- package/src/metadata.ts +94 -0
- package/src/pagination.ts +184 -0
- package/src/rest.ts +476 -0
- package/src/transport.ts +699 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* 变更通知通道(US-023 阶段 B)。
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* 通道只搬运**一个实体名**,不搬运行数据(D8):广播的对象是所有订阅者,推行数据在多租户
|
|
7
|
+
* 后端上直接是越权泄露;而且「这一行属不属于我的 `where`」只有服务端答得出。所以本模块的
|
|
8
|
+
* 出口就一个 —— 调用宿主给的失效上报口,剩下的(重跑哪些查询、要不要回远端)全归 core。
|
|
9
|
+
*
|
|
10
|
+
* 结构上它也必须是这样:本包按 US-212 AC#19 不实现也不调用 `upsertMany` 一族、不持有本地
|
|
11
|
+
* 存储句柄,通知通道自然更不能开第二条写本地行的路径。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { HttpConfigError } from './errors.js';
|
|
15
|
+
import type { HttpChangeFeedOptions, HttpChangeFeedUnavailableReport } from './http.interface.js';
|
|
16
|
+
|
|
17
|
+
/** `EventSource.readyState`:浏览器正在(重)连 */
|
|
18
|
+
const CONNECTING = 0;
|
|
19
|
+
|
|
20
|
+
/** 通知不带 `namespace` 时的取值,与 core `invalidateRemoteEntity` 的缺省一致 */
|
|
21
|
+
const DEFAULT_NAMESPACE = 'public';
|
|
22
|
+
|
|
23
|
+
/** 退避重连的起步延迟(毫秒) */
|
|
24
|
+
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1000;
|
|
25
|
+
|
|
26
|
+
/** 退避重连的延迟上限(毫秒) */
|
|
27
|
+
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 连续失败多少次之后不再把重连让给浏览器。
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* 让路的前提是「浏览器这一次能连上」。后端接受连接后立刻关闭时这个前提不成立,而浏览器
|
|
34
|
+
* 按它自己的固定节奏无限重试、完全不受本类的退避约束——那条节奏配上 D7 的「连上即全量
|
|
35
|
+
* 失效」就是一台稳定运转的失效风暴。次数用满即由本类收走节拍器。
|
|
36
|
+
*
|
|
37
|
+
* 取 3 而不是 1:单次 `CONNECTING` 是原生重连的正常形态(网络瞬断、服务端滚动重启),
|
|
38
|
+
* 一次就接管等于把浏览器那套成熟的重连逻辑弃之不用。
|
|
39
|
+
*/
|
|
40
|
+
const NATIVE_RECONNECT_LIMIT = 3;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 本模块真正用到的 `EventSource` 子集。
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* 写成结构类型而不是直接用 DOM 的 `EventSource`,是因为**运行时未必有它**:`lib.dom` 让它
|
|
47
|
+
* 在类型上恒存在,而 Node 里 `globalThis.EventSource` 是 `undefined`。用最小子集描述依赖,
|
|
48
|
+
* 「我们只用这 5 个成员」这件事就写在类型里,而不是散在实现中靠读代码归纳。
|
|
49
|
+
*/
|
|
50
|
+
interface EventSourceLike {
|
|
51
|
+
readonly readyState: number;
|
|
52
|
+
onopen: (() => void) | null;
|
|
53
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
54
|
+
onerror: (() => void) | null;
|
|
55
|
+
close(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 从 `globalThis` 取到的 `EventSource` 构造器形态 */
|
|
59
|
+
type EventSourceCtor = new (url: string, init?: { withCredentials?: boolean }) => EventSourceLike;
|
|
60
|
+
|
|
61
|
+
/** 一条解析成功的变更通知 */
|
|
62
|
+
interface ChangeNotification {
|
|
63
|
+
entity: string;
|
|
64
|
+
namespace: string;
|
|
65
|
+
/** 发起这次变更的客户端;缺省表示服务端没报,此时不做自回声抑制 */
|
|
66
|
+
clientId: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 参与 D7 全量失效的实体标识 */
|
|
70
|
+
export interface ChangeFeedEntity {
|
|
71
|
+
name: string;
|
|
72
|
+
namespace: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* {@link HttpChangeFeed} 与适配器之间的全部接触面。
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* 三个函数而不是一个适配器引用:通道拿不到适配器,也就无从调用它的读写口——
|
|
80
|
+
* 结构隔离用类型钉死,比在评审里叮嘱可靠。
|
|
81
|
+
*/
|
|
82
|
+
export interface ChangeFeedHost {
|
|
83
|
+
/** 已解析好的绝对 URL */
|
|
84
|
+
url: string;
|
|
85
|
+
options: HttpChangeFeedOptions;
|
|
86
|
+
/** 本机 clientId,用于自回声抑制(D6);每次现读,登录后被替换也跟得上 */
|
|
87
|
+
clientId: () => string | undefined;
|
|
88
|
+
/** 当前走本适配器 remote 槽位的实体(D7 的「已订阅实体」) */
|
|
89
|
+
entities: () => readonly ChangeFeedEntity[];
|
|
90
|
+
/** 失效上报口;签名里没有任何参数能承载行数据(D8) */
|
|
91
|
+
invalidate: (entity: string, namespace: string) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* SSE 变更通知通道。
|
|
96
|
+
*
|
|
97
|
+
* @remarks
|
|
98
|
+
* **连接成功 = 全量失效。** `onopen` 不区分首次连接与重连(D7):两者对客户端的含义完全一样
|
|
99
|
+
* ——「从这一刻起我能收到变更了,而这一刻之前的事我一无所知」。
|
|
100
|
+
*
|
|
101
|
+
* **失败绝不外泄到查询路径。** 本类不抛错、不产生 `NetworkOfflineError`:一条断掉的通知连接
|
|
102
|
+
* 不代表离线(可能只是后端没实现该端点),把它算进 `offlineFallback` 的判定,会让一次
|
|
103
|
+
* 查询在网络完好时被降级成读缓存。唯一的出口是
|
|
104
|
+
* {@link HttpChangeFeedOptions.onUnavailable} 这个诊断回调。
|
|
105
|
+
*/
|
|
106
|
+
export class HttpChangeFeed {
|
|
107
|
+
readonly #host: ChangeFeedHost;
|
|
108
|
+
readonly #baseDelayMs: number;
|
|
109
|
+
readonly #maxDelayMs: number;
|
|
110
|
+
#source: EventSourceLike | undefined;
|
|
111
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
#attempt = 0;
|
|
113
|
+
#stopped = true;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 当前连接建立的时刻;未连上时为 `undefined`。
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* 退避计数归零的判据是**连接活了多久**,不是 `open` 事件本身。「开了就断」的连接每轮都
|
|
120
|
+
* 触发一次 `open`,拿它归零会让指数退避一次都轮不到——而这正是那类后端唯一需要退避的场景。
|
|
121
|
+
*/
|
|
122
|
+
#openedAt: number | undefined;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param host - 通道与适配器之间的接触面
|
|
126
|
+
* @throws HttpConfigError 解析后的 URL 解析不出来,或退避配置不是 finite 正整数 / 上限小于起步值
|
|
127
|
+
*/
|
|
128
|
+
constructor(host: ChangeFeedHost) {
|
|
129
|
+
this.#host = host;
|
|
130
|
+
assertResolvedFeedUrl(host.url);
|
|
131
|
+
this.#baseDelayMs = readDelay(host.options.reconnectBaseDelayMs, 'reconnectBaseDelayMs');
|
|
132
|
+
this.#maxDelayMs = readDelay(
|
|
133
|
+
host.options.reconnectMaxDelayMs,
|
|
134
|
+
'reconnectMaxDelayMs',
|
|
135
|
+
DEFAULT_RECONNECT_MAX_DELAY_MS
|
|
136
|
+
);
|
|
137
|
+
if (this.#maxDelayMs < this.#baseDelayMs) {
|
|
138
|
+
// 不报错也不会崩,只会让「指数退避」静默退化成定长重试——那是配置写错了,
|
|
139
|
+
// 而不是一种可用的配置
|
|
140
|
+
throw new HttpConfigError(
|
|
141
|
+
`HTTP adapter config "changeFeed.reconnectMaxDelayMs" must be >= "changeFeed.reconnectBaseDelayMs" (${this.#baseDelayMs}), received ${this.#maxDelayMs}`,
|
|
142
|
+
'changeFeed.reconnectMaxDelayMs',
|
|
143
|
+
this.#maxDelayMs
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 建立连接。
|
|
150
|
+
*
|
|
151
|
+
* @remarks
|
|
152
|
+
* 可重复调用:先收口上一条连接再新建,与适配器 `connect()` 的「重连隐含断开」同一口径。
|
|
153
|
+
* 少了这一步,重复 `connect()` 会让旧连接失去最后一个引用却仍在收消息。
|
|
154
|
+
*/
|
|
155
|
+
start(): void {
|
|
156
|
+
this.#stopped = false;
|
|
157
|
+
// 显式意图,与「连上过」是两回事:调用方主动开通道时从头数,哪怕上一轮正卡在退避顶格上
|
|
158
|
+
this.#attempt = 0;
|
|
159
|
+
// 「先收口上一条」也包括上一代排下的退避定时器:留着它,它会在新连接建好之后触发,
|
|
160
|
+
// 把这条刚开的连接 #close() 掉再重开一条,顺带把 D7 全量失效重跑一遍
|
|
161
|
+
this.#clearTimer();
|
|
162
|
+
this.#connect();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 关闭连接并取消待执行的重连。 */
|
|
166
|
+
stop(): void {
|
|
167
|
+
this.#stopped = true;
|
|
168
|
+
this.#clearTimer();
|
|
169
|
+
this.#close();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#connect(): void {
|
|
173
|
+
this.#close();
|
|
174
|
+
const ctor = (globalThis as { EventSource?: EventSourceCtor }).EventSource;
|
|
175
|
+
if (typeof ctor !== 'function') {
|
|
176
|
+
// 不排重连:重试再多次也变不出一个全局构造器,那只是让日志滚起来
|
|
177
|
+
this.#report({
|
|
178
|
+
reason: 'unsupported-runtime',
|
|
179
|
+
attempt: 0,
|
|
180
|
+
message: `change feed is enabled but this runtime has no global "EventSource"; no connection will be attempted for ${this.#host.url}`
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const source = new ctor(this.#host.url, { withCredentials: this.#host.options.withCredentials === true });
|
|
186
|
+
source.onopen = this.#handleOpen;
|
|
187
|
+
source.onmessage = this.#handleMessage;
|
|
188
|
+
source.onerror = this.#handleError;
|
|
189
|
+
this.#source = source;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// 非法 URL 会让构造器同步抛 SyntaxError。让它冒到 connect() 就成了
|
|
192
|
+
// 「通知配错 → 整个适配器连不上」,而通道按 AC#17 不该有这种权力
|
|
193
|
+
this.#fail(undefined, error instanceof Error ? error.message : String(error));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 对每个已订阅实体各上报一次失效(D7) */
|
|
198
|
+
#invalidateAll(): void {
|
|
199
|
+
for (const entity of this.#host.entities()) {
|
|
200
|
+
this.#host.invalidate(entity.name, entity.namespace);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 记一次连接失败:更新退避计数、决定这一次由谁重连。
|
|
206
|
+
*
|
|
207
|
+
* @param readyState - 失败时连接的状态;`CONNECTING` 表示浏览器的原生重连正在路上
|
|
208
|
+
* @param detail - 写进诊断消息的现象描述
|
|
209
|
+
*
|
|
210
|
+
* @remarks
|
|
211
|
+
* **归零放在失败时判,而不是 `open` 时做。** `open` 那一刻无从知道这条连接能活多久,
|
|
212
|
+
* 到失败时才知道。判据是「有没有活过一个退避周期」:连接连我们本来就要等的那段时间都
|
|
213
|
+
* 撑不过,它就没有取得任何进展,退避计数不该被它清掉。
|
|
214
|
+
*
|
|
215
|
+
* **让给浏览器是有次数上限的。** 单次 `CONNECTING` 是原生重连的正常形态,让路能省下
|
|
216
|
+
* 一条重复连接;但反复失败说明浏览器那套也连不上,此时继续让路就等于把节奏永久交给一个
|
|
217
|
+
* 不受退避约束的循环。接管前必须先 `#close()`:浏览器的下一次重试还挂在那条连接上,
|
|
218
|
+
* 留着它就是两条连接收同一份广播。
|
|
219
|
+
*/
|
|
220
|
+
#fail(readyState: number | undefined, detail: string): void {
|
|
221
|
+
if (this.#wasStable()) {
|
|
222
|
+
this.#attempt = 0;
|
|
223
|
+
}
|
|
224
|
+
this.#openedAt = undefined;
|
|
225
|
+
this.#attempt++;
|
|
226
|
+
|
|
227
|
+
const deferToNative = readyState === CONNECTING && this.#attempt <= NATIVE_RECONNECT_LIMIT;
|
|
228
|
+
const retryInMs = deferToNative ? undefined : this.#nextDelay();
|
|
229
|
+
this.#report({
|
|
230
|
+
reason: 'connection-error',
|
|
231
|
+
attempt: this.#attempt,
|
|
232
|
+
readyState,
|
|
233
|
+
retryInMs,
|
|
234
|
+
message: `change feed connection to ${this.#host.url} failed (${detail}); EventSource does not expose the status code, so the cause may be an unimplemented endpoint, an authentication failure, or a network error`
|
|
235
|
+
});
|
|
236
|
+
if (retryInMs === undefined) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.#close();
|
|
240
|
+
this.#schedule(retryInMs);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 上一条连接是否活过了一个退避周期——够久才算「连上过」 */
|
|
244
|
+
#wasStable(): boolean {
|
|
245
|
+
return this.#openedAt !== undefined && Date.now() - this.#openedAt >= this.#baseDelayMs;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#nextDelay(): number {
|
|
249
|
+
return Math.min(this.#baseDelayMs * 2 ** (this.#attempt - 1), this.#maxDelayMs);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#schedule(delayMs: number): void {
|
|
253
|
+
this.#clearTimer();
|
|
254
|
+
this.#timer = setTimeout(() => {
|
|
255
|
+
this.#timer = undefined;
|
|
256
|
+
this.#connect();
|
|
257
|
+
}, delayMs);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#clearTimer(): void {
|
|
261
|
+
if (this.#timer !== undefined) {
|
|
262
|
+
clearTimeout(this.#timer);
|
|
263
|
+
this.#timer = undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 关闭当前连接。
|
|
269
|
+
*
|
|
270
|
+
* @remarks
|
|
271
|
+
* 先摘回调再 `close()`:`close()` 之后到 GC 之前,这条连接仍可能把最后一个事件送进来,
|
|
272
|
+
* 而那个事件属于上一代连接。摘干净了「断开后不再上报」才是结构保证。
|
|
273
|
+
*/
|
|
274
|
+
#close(): void {
|
|
275
|
+
const source = this.#source;
|
|
276
|
+
if (source === undefined) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.#source = undefined;
|
|
280
|
+
source.onopen = null;
|
|
281
|
+
source.onmessage = null;
|
|
282
|
+
source.onerror = null;
|
|
283
|
+
source.close();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#report(facts: Omit<HttpChangeFeedUnavailableReport, 'url'>): void {
|
|
287
|
+
const hook = this.#host.options.onUnavailable;
|
|
288
|
+
if (!hook) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
hook({ ...facts, url: this.#host.url });
|
|
293
|
+
} catch {
|
|
294
|
+
/* 此刻正在报告的就是「本通道没有输出通道」,诊断自己失败时没有第二条路可走 */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#reportNotification(notification: ChangeNotification, suppressed: boolean): void {
|
|
299
|
+
const hook = this.#host.options.onNotification;
|
|
300
|
+
if (!hook) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
hook({
|
|
305
|
+
url: this.#host.url,
|
|
306
|
+
entity: notification.entity,
|
|
307
|
+
namespace: notification.namespace,
|
|
308
|
+
clientId: notification.clientId,
|
|
309
|
+
suppressed
|
|
310
|
+
});
|
|
311
|
+
} catch {
|
|
312
|
+
/* 与 #report 同一条口径:诊断口失败绝不能带塌失效上报 */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
readonly #handleOpen = (): void => {
|
|
317
|
+
// 只记时刻,不动退避计数:这条连接能不能算「连上过」,要等它断的时候才知道(见 #fail)
|
|
318
|
+
this.#openedAt = Date.now();
|
|
319
|
+
this.#invalidateAll();
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
readonly #handleMessage = (event: { data: unknown }): void => {
|
|
323
|
+
if (this.#stopped) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const notification = parseNotification(event.data);
|
|
327
|
+
if (notification === undefined) {
|
|
328
|
+
this.#report({
|
|
329
|
+
reason: 'malformed-message',
|
|
330
|
+
attempt: this.#attempt,
|
|
331
|
+
data: typeof event.data === 'string' ? event.data : undefined,
|
|
332
|
+
message: `change feed message from ${this.#host.url} carries no usable entity name; expected JSON like {"entity":"Recipe"}`
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// 两边都没有 clientId 时**不算**自回声:`context.clientId` 由 `RxDB.init()` 生成,
|
|
337
|
+
// 没跑过 init() 的实例上它是 undefined,拿 undefined === undefined 当命中,
|
|
338
|
+
// 会让这类场景一条通知都收不到
|
|
339
|
+
const suppressed = notification.clientId !== undefined && notification.clientId === this.#host.clientId();
|
|
340
|
+
this.#reportNotification(notification, suppressed);
|
|
341
|
+
if (suppressed) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
this.#host.invalidate(notification.entity, notification.namespace);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
readonly #handleError = (): void => {
|
|
348
|
+
if (this.#stopped) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const readyState = this.#source?.readyState;
|
|
352
|
+
this.#fail(readyState, `readyState=${String(readyState)}`);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 解析一条通知。
|
|
358
|
+
*
|
|
359
|
+
* @param data - `MessageEvent.data`
|
|
360
|
+
* @returns 解析结果;读不出实体名时为 `undefined`
|
|
361
|
+
*
|
|
362
|
+
* @remarks
|
|
363
|
+
* 读不懂就整条丢弃,不做任何补救:这条通知唯一的信息就是实体名,名字都没有的话,
|
|
364
|
+
* 「猜一个」等于随机刷新一张表。
|
|
365
|
+
*/
|
|
366
|
+
const parseNotification = (data: unknown): ChangeNotification | undefined => {
|
|
367
|
+
if (typeof data !== 'string') {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
const parsed = parseJson(data);
|
|
371
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
const record = parsed as Record<string, unknown>;
|
|
375
|
+
const entity = record['entity'];
|
|
376
|
+
if (typeof entity !== 'string' || entity.length === 0) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
const namespace = record['namespace'];
|
|
380
|
+
const clientId = record['clientId'];
|
|
381
|
+
return {
|
|
382
|
+
entity,
|
|
383
|
+
namespace: typeof namespace === 'string' && namespace.length > 0 ? namespace : DEFAULT_NAMESPACE,
|
|
384
|
+
clientId: typeof clientId === 'string' ? clientId : undefined
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** `JSON.parse` 的非抛错版本:读不懂的载荷是服务端的事,不该变成客户端异常 */
|
|
389
|
+
const parseJson = (text: string): unknown => {
|
|
390
|
+
try {
|
|
391
|
+
return JSON.parse(text);
|
|
392
|
+
} catch {
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 读一个退避延迟配置。
|
|
399
|
+
*
|
|
400
|
+
* @param value - 用户传入值,`undefined` 取默认
|
|
401
|
+
* @param field - 出错时写进错误的字段名(不带 `changeFeed.` 前缀)
|
|
402
|
+
* @param fallback - 该字段的默认值
|
|
403
|
+
* @returns 已校验的毫秒数
|
|
404
|
+
* @throws HttpConfigError 不是 finite 正整数
|
|
405
|
+
*
|
|
406
|
+
* @remarks
|
|
407
|
+
* 判据与 `resolveHttpConfig` 里那份逐字一致:`1.5` 会让退避延迟落在半毫秒上,
|
|
408
|
+
* `Infinity` 则等于「第一次失败之后永不重连」——那正是退避配置存在的反面。两者都由
|
|
409
|
+
* `Number.isInteger` 一并挡下(它对 `NaN` / `±Infinity` 同样返回 `false`),不必再加一道
|
|
410
|
+
* `Number.isFinite`:那道判断永远为假,读代码的人却会以为它在挡什么。
|
|
411
|
+
*/
|
|
412
|
+
const readDelay = (
|
|
413
|
+
value: number | undefined,
|
|
414
|
+
field: 'reconnectBaseDelayMs' | 'reconnectMaxDelayMs',
|
|
415
|
+
fallback: number = DEFAULT_RECONNECT_BASE_DELAY_MS
|
|
416
|
+
): number => {
|
|
417
|
+
const resolved = value ?? fallback;
|
|
418
|
+
if (!Number.isInteger(resolved) || resolved < 1) {
|
|
419
|
+
throw new HttpConfigError(
|
|
420
|
+
`HTTP adapter config "changeFeed.${field}" must be a finite integer >= 1, received ${String(resolved)}`,
|
|
421
|
+
`changeFeed.${field}`,
|
|
422
|
+
resolved
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return resolved;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 校验通知端点。
|
|
430
|
+
*
|
|
431
|
+
* @param url - 用户配置的 `changeFeed.url`
|
|
432
|
+
* @throws HttpConfigError 不是非空字符串
|
|
433
|
+
*
|
|
434
|
+
* @remarks
|
|
435
|
+
* 空串会被拼成 `baseUrl/`,于是通知连接打到 API 根路径上——那里多半返回 HTML,
|
|
436
|
+
* 表现为一条永远在退避重连的连接,而不是一句配置错误。
|
|
437
|
+
*/
|
|
438
|
+
export const assertChangeFeedUrl = (url: string): void => {
|
|
439
|
+
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
440
|
+
throw new HttpConfigError('HTTP adapter config "changeFeed.url" must be a non-empty string', 'changeFeed.url', url);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 校验拼接完成的通知端点确实是个能解析的 URL。
|
|
446
|
+
*
|
|
447
|
+
* @param url - {@link ChangeFeedHost.url},即 `joinUrl(baseUrl, changeFeed.url)` 的结果
|
|
448
|
+
* @throws HttpConfigError URL 解析不出来
|
|
449
|
+
*
|
|
450
|
+
* @remarks
|
|
451
|
+
* 判在构造期而不是等 `#connect()`,理由与空串被 {@link assertChangeFeedUrl} 拦下**完全一样**,
|
|
452
|
+
* 只是漏网的路径不同:`joinUrl` 见到 `http(s)://` 开头就原样透出,于是
|
|
453
|
+
* `changeFeed.url: 'https://'` 绕过非空校验,一路走到 `new EventSource()` 才同步抛
|
|
454
|
+
* `SyntaxError`。而 `#connect()` 的 `catch` 把它交给 `#fail()` 当瞬时故障退避重试 ——
|
|
455
|
+
* URL 在通道的一生里是常量,重试一万次也还是同一个 `SyntaxError`,换来的是一条每 30 秒
|
|
456
|
+
* (退避顶格)报一次、永远好不了的连接。这与同一函数里 `unsupported-runtime`「不排重连」
|
|
457
|
+
* 的判断也自相矛盾:两者都是重试变不出来的东西。
|
|
458
|
+
*
|
|
459
|
+
* 留在 `#connect()` 里的那条 `catch` **不去掉**:它罩的是真正可能好转的构造失败
|
|
460
|
+
* (运行时把 `EventSource` 换成了会抛的实现、资源耗尽),那类才该退避。
|
|
461
|
+
*/
|
|
462
|
+
const assertResolvedFeedUrl = (url: string): void => {
|
|
463
|
+
if (!URL.canParse(url)) {
|
|
464
|
+
throw new HttpConfigError(
|
|
465
|
+
`HTTP adapter config "changeFeed.url" resolves to "${url}", which is not a parsable URL`,
|
|
466
|
+
'changeFeed.url',
|
|
467
|
+
url
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
};
|
package/src/chunking.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* `findByIds` 的分块循环(US-212 AC#8、#9、#33)。
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* 不要发明「一次 POST 全量 id」然后在网关 413 时返回空——那是 supabase 侧
|
|
7
|
+
* `#findByIdsInChunks` 已经付过学费的同一个坑,只是换了个门。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { HttpHandlerContractError } from './errors.js';
|
|
11
|
+
import type { FindByIdsContext, FindByIdsHandler, HttpNumericConfig } from './http.interface.js';
|
|
12
|
+
import type { HttpTransport } from './transport.js';
|
|
13
|
+
|
|
14
|
+
/** 分块所需的协作方 */
|
|
15
|
+
export interface ChunkingDeps {
|
|
16
|
+
transport: HttpTransport;
|
|
17
|
+
handler: FindByIdsHandler;
|
|
18
|
+
config: Pick<HttpNumericConfig, 'idChunkSize'>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 按固定尺寸切分 id 列表 */
|
|
22
|
+
const chunk = (ids: string[], size: number): string[][] => {
|
|
23
|
+
const chunks: string[][] = [];
|
|
24
|
+
for (let index = 0; index < ids.length; index += size) {
|
|
25
|
+
chunks.push(ids.slice(index, index + size));
|
|
26
|
+
}
|
|
27
|
+
return chunks;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 分块拉取整行并按块序合并。
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* **串行**,与 supabase 的 `#findByIdsInChunks` 同款:首块出错即停,不再打后续请求。
|
|
35
|
+
* 并发能快一点,但会在远端已经出问题时继续加压,而结果反正是整体 reject。
|
|
36
|
+
*
|
|
37
|
+
* 返回 `Promise` 的理由同 {@link fetchAllMetadataPages}:调用方 `from(promise)` 一层就
|
|
38
|
+
* 天然「恰好发射一次再 complete」(AC#33)。逐块发射会让 `forkJoin` 只留最后一块,
|
|
39
|
+
* 前面各块拉回来的完整行当场丢失——而那些行正是要写进本地缓存的数据。
|
|
40
|
+
*
|
|
41
|
+
* @param deps - transport、handler 与分块尺寸
|
|
42
|
+
* @param ctx - 实体名与完整 id 列表
|
|
43
|
+
* @returns 各块合并后的原始行,顺序即块序
|
|
44
|
+
* @throws 任一块失败即整体抛出该块的错误,**不**降级成空块继续合并
|
|
45
|
+
*/
|
|
46
|
+
export const findByIdsInChunks = async (
|
|
47
|
+
deps: ChunkingDeps,
|
|
48
|
+
ctx: { entityName: string; ids: string[] }
|
|
49
|
+
): Promise<unknown[]> => {
|
|
50
|
+
const { transport, handler, config } = deps;
|
|
51
|
+
const rows: unknown[] = [];
|
|
52
|
+
for (const ids of chunk(ctx.ids, config.idChunkSize)) {
|
|
53
|
+
const chunkCtx: FindByIdsContext = { entityName: ctx.entityName, ids };
|
|
54
|
+
const body = await transport.sendJson(handler.request(chunkCtx), 'findByIds', ctx.entityName);
|
|
55
|
+
const parsed = handler.parse(body, chunkCtx);
|
|
56
|
+
if (!Array.isArray(parsed)) {
|
|
57
|
+
throw new HttpHandlerContractError(
|
|
58
|
+
'findByIds',
|
|
59
|
+
ctx.entityName,
|
|
60
|
+
`expected an array of rows, received ${typeof parsed}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
// 少于本块 id 数是**合法**的(远端确实删了),不重试、不补空对象——
|
|
64
|
+
// 把它和「这一块请求失败」区分开正是本模块的价值
|
|
65
|
+
rows.push(...parsed);
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
};
|