@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -0
  3. package/dist/RxDBAdapterHttp.d.ts +251 -0
  4. package/dist/RxDBAdapterHttp.d.ts.map +1 -0
  5. package/dist/change-feed.d.ts +66 -0
  6. package/dist/change-feed.d.ts.map +1 -0
  7. package/dist/chunking.d.ts +29 -0
  8. package/dist/chunking.d.ts.map +1 -0
  9. package/dist/conditional-cache.d.ts +175 -0
  10. package/dist/conditional-cache.d.ts.map +1 -0
  11. package/dist/config.d.ts +30 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/errors.d.ts +215 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/handler-contract.d.ts +41 -0
  16. package/dist/handler-contract.d.ts.map +1 -0
  17. package/dist/http.interface.d.ts +519 -0
  18. package/dist/http.interface.d.ts.map +1 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +864 -0
  22. package/dist/metadata.d.ts +15 -0
  23. package/dist/metadata.d.ts.map +1 -0
  24. package/dist/pagination.d.ts +30 -0
  25. package/dist/pagination.d.ts.map +1 -0
  26. package/dist/rest.d.ts +94 -0
  27. package/dist/rest.d.ts.map +1 -0
  28. package/dist/transport.d.ts +174 -0
  29. package/dist/transport.d.ts.map +1 -0
  30. package/package.json +64 -0
  31. package/src/RxDBAdapterHttp.ts +786 -0
  32. package/src/change-feed.ts +470 -0
  33. package/src/chunking.ts +68 -0
  34. package/src/conditional-cache.ts +271 -0
  35. package/src/config.ts +83 -0
  36. package/src/errors.ts +295 -0
  37. package/src/handler-contract.ts +77 -0
  38. package/src/http.interface.ts +554 -0
  39. package/src/index.ts +25 -0
  40. package/src/metadata.ts +94 -0
  41. package/src/pagination.ts +184 -0
  42. package/src/rest.ts +476 -0
  43. package/src/transport.ts +699 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * metadata 的 wire 边界:校验并**规范化** `updatedAt`(US-212 AC#14)。
4
+ *
5
+ * @remarks
6
+ * core 的 `diffMetadata` 直接 `remote.updatedAt > localUpdatedAt` 比字符串,而本地侧恒是
7
+ * `toISOString()` 的形态。所以「是合法 ISO 8601」不够——形态不同的两侧比字典序会得出与
8
+ * 时间序相反的结论,且**全程没有异常**:查询照常返回,错的只是新鲜度判断。
9
+ *
10
+ * 这一层还负责让 metadata 通道**绕开实体解码**:只透出 `id` 与 `updatedAt` 两个字段,
11
+ * 都保持 `string`。一旦 `updatedAt` 变成 `Date`,与字符串比较会走 number 提示、
12
+ * 字符串那侧转成 `NaN`,比较恒为 `false` —— 所有行判 fresh,远端更新永远拉不下来。
13
+ */
14
+
15
+ import type { QueryCacheEntityMetadata } from '@aiao/rxdb';
16
+ import { HttpInvalidMetadataError } from './errors.js';
17
+
18
+ /**
19
+ * 带**明确时区标识**的 ISO 8601 date-time。
20
+ *
21
+ * @remarks
22
+ * 秒与小数秒可缺省(后面会补齐),但时区标识不可缺:`2026-08-23T10:00:00` 在 JS 里
23
+ * 按**本地时区**解析,同一份响应在不同机器上会归一成不同的 UTC 值。那是不确定性,
24
+ * 不是不规范,所以这里拒绝而不是猜。
25
+ *
26
+ * 顺带把 `Date.parse` 的宽松形态挡在外面——`'Aug 23, 2026'` 它照收不误,
27
+ * 但那不是本包与远端约定的 wire 形式。
28
+ */
29
+ const ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})$/;
30
+
31
+ /** 判定值是可索引的普通对象(`null` 不算) */
32
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
33
+
34
+ /**
35
+ * 把一个 `updatedAt` 归一成 `toISOString()` 同形的串。
36
+ *
37
+ * @throws HttpInvalidMetadataError 不是字符串、不是带时区的 ISO 8601、或不是真实存在的时刻
38
+ */
39
+ const canonicalizeUpdatedAt = (entityName: string, id: string, updatedAt: unknown): string => {
40
+ if (typeof updatedAt !== 'string') {
41
+ throw new HttpInvalidMetadataError(
42
+ entityName,
43
+ `row "${id}" has updatedAt of type ${typeof updatedAt}, expected an ISO 8601 string`
44
+ );
45
+ }
46
+ if (!ISO_8601_WITH_ZONE.test(updatedAt)) {
47
+ throw new HttpInvalidMetadataError(
48
+ entityName,
49
+ `row "${id}" has updatedAt "${updatedAt}", expected ISO 8601 with an explicit time zone`
50
+ );
51
+ }
52
+ const parsed = new Date(updatedAt);
53
+ if (Number.isNaN(parsed.getTime())) {
54
+ throw new HttpInvalidMetadataError(
55
+ entityName,
56
+ `row "${id}" has updatedAt "${updatedAt}", which is not a real instant`
57
+ );
58
+ }
59
+ return parsed.toISOString();
60
+ };
61
+
62
+ /** 取出并校验 `id` */
63
+ const readId = (entityName: string, row: Record<string, unknown>): string => {
64
+ const { id } = row;
65
+ if (typeof id !== 'string' || id.length === 0) {
66
+ throw new HttpInvalidMetadataError(entityName, `row has id ${JSON.stringify(id)}, expected a non-empty string`);
67
+ }
68
+ return id;
69
+ };
70
+
71
+ /**
72
+ * 校验并规范化 handler 解析出的 metadata 行。
73
+ *
74
+ * @remarks
75
+ * **不得直接透传远端的串**,哪怕它合法:合法与规范是两件事,而破坏字典序的四种形态
76
+ * (时区偏移、缺毫秒、`+00:00` 代替 `Z`、多于 3 位小数秒)全都合法。
77
+ *
78
+ * @param entityName - 实体名,出错时写进错误便于定位
79
+ * @param rows - handler `parse` 出来的行,尚未校验
80
+ * @returns 只含 `id` 与 `updatedAt` 的规范化 metadata
81
+ * @throws HttpInvalidMetadataError 不是数组、行不是对象、缺字段或 `updatedAt` 不合契约
82
+ */
83
+ export const canonicalizeMetadata = (entityName: string, rows: unknown[]): QueryCacheEntityMetadata[] => {
84
+ if (!Array.isArray(rows)) {
85
+ throw new HttpInvalidMetadataError(entityName, `expected an array of metadata rows, received ${typeof rows}`);
86
+ }
87
+ return rows.map(row => {
88
+ if (!isRecord(row)) {
89
+ throw new HttpInvalidMetadataError(entityName, `expected a metadata object, received ${JSON.stringify(row)}`);
90
+ }
91
+ const id = readId(entityName, row);
92
+ return { id, updatedAt: canonicalizeUpdatedAt(entityName, id, row['updatedAt']) };
93
+ });
94
+ };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * `fetchMetadata` 的翻页循环(US-212 AC#5、#6、#7、#23)。
4
+ *
5
+ * @remarks
6
+ * 这是本包最贵的一段学费所在:PostgREST 的 `max-rows` 静默截断已经在 supabase 侧付过一次。
7
+ * metadata 少一页,那些 id 就会被上层判成「远端已删除」,叠加 US-020 阶段 B 的真
8
+ * `deleteByIds` 会把还活着的远端行从本地抹掉。所以本模块的每条异常路径都是
9
+ * **抛错**,没有一条返回「已经拿到的那部分」。
10
+ */
11
+
12
+ import type { QueryCacheEntityMetadata, RuleGroup } from '@aiao/rxdb';
13
+ import { HttpHandlerContractError, HttpPaginationError } from './errors.js';
14
+ import type { FetchMetadataContext, FetchMetadataHandler, HttpNumericConfig } from './http.interface.js';
15
+ import { canonicalizeMetadata } from './metadata.js';
16
+ import type { HttpTransport } from './transport.js';
17
+
18
+ /** 翻页所需的协作方 */
19
+ export interface PaginationDeps {
20
+ transport: HttpTransport;
21
+ handler: FetchMetadataHandler;
22
+ config: Pick<HttpNumericConfig, 'pageSize' | 'maxEmptyPages' | 'maxPages'>;
23
+ }
24
+
25
+ /**
26
+ * 本次查询锁定的翻页模式。
27
+ *
28
+ * @remarks
29
+ * 两个取值都描述**靠什么推进**,对应下面两条互斥的终止判据;不描述 JSON 长什么样。
30
+ */
31
+ type PageShape = 'offset' | 'token';
32
+
33
+ /** 一页解析后的统一形态,抹掉两种 wire 形态的差异 */
34
+ interface NormalizedPage {
35
+ shape: PageShape;
36
+ rows: unknown[];
37
+ nextPageToken?: string;
38
+ }
39
+
40
+ /**
41
+ * 把判别式返回归一,顺带记下形态用于换形态检测。
42
+ *
43
+ * @remarks
44
+ * 两种合法形态之外的返回值在这里就拦住。放过去的话,`rows` 会是 `undefined`,
45
+ * 最终以「这一页没有行」的面目终止翻页——又一次静默截断。
46
+ *
47
+ * 改名前的 `nextCursor` 单独报一条:它的 `rows` 是合法数组,能一路走到底,只是
48
+ * `nextPageToken` 读出 `undefined`,于是**首页即末页**。症状是整表只剩第一页、
49
+ * 其余 id 被上层判成远端已删除,而全程没有任何错误——正是这个包整章在防的那一种。
50
+ */
51
+ const normalizePage = (entityName: string, result: unknown): NormalizedPage => {
52
+ if (Array.isArray(result)) {
53
+ return { shape: 'offset', rows: result };
54
+ }
55
+ // 每个键都读成 `unknown`:这是 handler 返回的 wire 数据,声明成 `string` 只是把
56
+ // 「没校验」写成「已保证」,下面三条校验才是真正的边界
57
+ const page = result as { rows?: unknown; nextPageToken?: unknown; nextCursor?: unknown } | null;
58
+ if (!page || !Array.isArray(page.rows)) {
59
+ throw new HttpHandlerContractError(
60
+ 'fetchMetadata',
61
+ entityName,
62
+ `expected an array of rows or { rows, nextPageToken }, received ${JSON.stringify(result)}`
63
+ );
64
+ }
65
+ // 两个键都在时不算遗留:handler 已经给出 nextPageToken,说明它认得新契约
66
+ if (page.nextPageToken === undefined && page.nextCursor !== undefined) {
67
+ throw new HttpHandlerContractError(
68
+ 'fetchMetadata',
69
+ entityName,
70
+ 'returned the removed key "nextCursor"; rename it to "nextPageToken" (reading it as undefined would end pagination at the first page)'
71
+ );
72
+ }
73
+ // 类型只是断言,wire 上什么都可能来。`null` 尤其危险:它 `!== undefined`,于是被当成
74
+ // 一个合法 token 发进下一页请求,直到第二次拿到同一个 `null` 才以「token 不推进」
75
+ // 的面目报出来——错误指向了一个不存在的死循环,真正的问题(handler 该发 undefined
76
+ // 却发了 null)反倒不在信息里
77
+ if (page.nextPageToken !== undefined && typeof page.nextPageToken !== 'string') {
78
+ throw new HttpHandlerContractError(
79
+ 'fetchMetadata',
80
+ entityName,
81
+ `expected "nextPageToken" to be a string or undefined, received ${JSON.stringify(page.nextPageToken)}`
82
+ );
83
+ }
84
+ return { shape: 'token', rows: page.rows, nextPageToken: page.nextPageToken };
85
+ };
86
+
87
+ /**
88
+ * 翻完一次 `fetchMetadata` 的所有页并合并。
89
+ *
90
+ * @remarks
91
+ * 返回 `Promise` 而不是 `Observable` 是刻意的:调用方用 `from(promise)` 包一层就天然
92
+ * 满足「恰好发射一次再 complete」(AC#23)。把 Observable 的构造留在循环里,等于给
93
+ * 「每页发一次」留了门——而 `forkJoin` 只留最后一次发射,前面各页当场丢失。
94
+ *
95
+ * 翻页模式按**首页的返回形状**锁定,中途换形态即抛错:两种形态各有各的终止判据,
96
+ * 混用会让它们互相盖掉,正是本包要防的静默截断。
97
+ *
98
+ * @param deps - transport、handler 与三个翻页相关配置
99
+ * @param ctx - 实体名与查询条件,逐页原样透传给 handler
100
+ * @returns 所有页合并、且逐页 canonicalize 过的 metadata
101
+ * @throws HttpPaginationError 换形态 / token 不推进 / 连续空页触顶 / 总页数触顶
102
+ */
103
+ export const fetchAllMetadataPages = async (
104
+ deps: PaginationDeps,
105
+ ctx: { entityName: string; where: RuleGroup<unknown> }
106
+ ): Promise<QueryCacheEntityMetadata[]> => {
107
+ const { transport, handler, config } = deps;
108
+ const all: QueryCacheEntityMetadata[] = [];
109
+ let shape: PageShape | undefined;
110
+ let offset = 0;
111
+ let pageToken: string | undefined;
112
+ let emptyStreak = 0;
113
+
114
+ for (let page = 1; ; page++) {
115
+ if (page > config.maxPages) {
116
+ throw new HttpPaginationError(
117
+ 'max_pages',
118
+ `fetchMetadata for "${ctx.entityName}" exceeded maxPages=${config.maxPages}; refusing to return a truncated result`
119
+ );
120
+ }
121
+ const pageCtx: FetchMetadataContext = { ...ctx, offset, limit: config.pageSize, pageToken };
122
+ const body = await transport.sendJson(handler.request(pageCtx), 'fetchMetadata', ctx.entityName);
123
+ const parsed = normalizePage(ctx.entityName, handler.parse(body, pageCtx));
124
+ if (shape && parsed.shape !== shape) {
125
+ throw new HttpPaginationError(
126
+ 'shape_switch',
127
+ `fetchMetadata for "${ctx.entityName}" started in ${shape} mode but page ${page} returned ${parsed.shape} mode`
128
+ );
129
+ }
130
+ shape = parsed.shape;
131
+ all.push(...canonicalizeMetadata(ctx.entityName, parsed.rows));
132
+
133
+ if (shape === 'offset') {
134
+ // 短页即末页——这条判据的前提是服务端保证「不会因限流或 max-rows 提前返回短页」,
135
+ // 保证不了的服务端 MUST 用 token 形态,适配器在客户端侧无从检测
136
+ if (parsed.rows.length < config.pageSize) {
137
+ return all;
138
+ }
139
+ offset += parsed.rows.length;
140
+ continue;
141
+ }
142
+ if (parsed.nextPageToken === undefined) {
143
+ return all;
144
+ }
145
+ if (parsed.nextPageToken === pageToken) {
146
+ throw new HttpPaginationError(
147
+ 'page_token_not_advancing',
148
+ `fetchMetadata for "${ctx.entityName}" received the same nextPageToken "${pageToken}" twice; refusing to loop forever`
149
+ );
150
+ }
151
+ emptyStreak = assertEmptyStreak(ctx.entityName, parsed.rows.length, emptyStreak, config.maxEmptyPages);
152
+ pageToken = parsed.nextPageToken;
153
+ }
154
+ };
155
+
156
+ /**
157
+ * 维护连续空页计数并在触顶时抛错。
158
+ *
159
+ * @remarks
160
+ * 只在 token 形态调用:offset 形态下空页就是短页,本来就是正常终止条件,不计数。
161
+ * **连续计数、非空即清零**——空一页不是故障,空**超过** `maxEmptyPages` 页才是。
162
+ * 计数器按单次 `fetchMetadata` 调用生存,不跨查询累积。
163
+ *
164
+ * 判据是 `>` 不是 `>=`:字段语义是「容忍几个连续空页」,`maxEmptyPages: N` 就该真的
165
+ * 放过 N 个。写成 `>=` 会让每个取值都少容忍一个,顺带把 `1` 压成和 `0` 一模一样——
166
+ * 而 `config.ts` 的 `ZERO_ALLOWED` 专为 `maxEmptyPages` 开了 `0` 这个口子,
167
+ * 只有在 `1` 有不同语义时那个口子才不是冗余的。
168
+ *
169
+ * `maxEmptyPages: 0` 仍落在同一条判据里:空页让计数变成 `1`,`1 > 0` 即刻抛错,
170
+ * 语义正是「一个空页都不容忍」。
171
+ */
172
+ const assertEmptyStreak = (entityName: string, rowCount: number, streak: number, maxEmptyPages: number): number => {
173
+ if (rowCount > 0) {
174
+ return 0;
175
+ }
176
+ const next = streak + 1;
177
+ if (next > maxEmptyPages) {
178
+ throw new HttpPaginationError(
179
+ 'empty_page_limit',
180
+ `fetchMetadata for "${entityName}" received ${next} consecutive empty pages (maxEmptyPages=${maxEmptyPages}); the remote is spinning`
181
+ );
182
+ }
183
+ return next;
184
+ };