@baukit/sync-client 0.7.2 → 0.7.4

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.
@@ -0,0 +1,344 @@
1
+ import {
2
+ SyncAuthError,
3
+ SyncLocalApplyError,
4
+ SyncNetworkError,
5
+ SyncPartitionMismatchError,
6
+ SyncPayloadCompatibilityError,
7
+ SyncRateLimitError,
8
+ SyncServerError,
9
+ type SyncTransportError,
10
+ } from './error.js';
11
+
12
+ /** Minimal `fetch` shape the transport depends on. */
13
+ export interface SyncRequestInit {
14
+ method?: string;
15
+ headers?: Record<string, string>;
16
+ body?: string;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export type SyncFetch = (input: string, init?: SyncRequestInit) => Promise<SyncFetchResponse>;
21
+
22
+ /** A product API client's decoded request function. */
23
+ export type SyncPrebuiltRequest = <T>(input: string, init?: SyncRequestInit) => Promise<T>;
24
+
25
+ export interface SyncResponseHeaders {
26
+ get(name: string): string | null;
27
+ }
28
+
29
+ /** Minimal `Response` shape the transport depends on. */
30
+ export interface SyncFetchResponse {
31
+ readonly ok: boolean;
32
+ readonly status: number;
33
+ readonly headers?: SyncResponseHeaders;
34
+ text(): Promise<string>;
35
+ }
36
+
37
+ export interface SyncFetchTransportOptions {
38
+ /** Absolute base URL of the sync API, without a trailing slash. */
39
+ baseUrl: string;
40
+ fetch: SyncFetch;
41
+ /** Resolves the headers that authenticate a request, per attempt. */
42
+ authHeaders: () => Promise<Record<string, string>> | Record<string, string>;
43
+ /**
44
+ * Header carrying the local-data partition the caller expects to talk to.
45
+ * Defaults to `X-Partition-Id`.
46
+ */
47
+ partitionHeader?: string;
48
+ /**
49
+ * Error code the server returns when the caller's partition no longer
50
+ * exists. Defaults to `partition_identity_mismatch`.
51
+ */
52
+ partitionMismatchCode?: string;
53
+ /** Milliseconds used when `Retry-After` is missing or unusable. Defaults to 60 seconds. */
54
+ retryAfterFallbackMs?: number;
55
+ /** Clock used to resolve `Retry-After`. Defaults to `Date.now`. */
56
+ now?: () => number;
57
+ }
58
+
59
+ export interface SyncPrebuiltRequestTransportOptions {
60
+ /**
61
+ * Sends and decodes a request through a product API client. The function owns
62
+ * base-URL resolution, authentication recovery, and failure classification.
63
+ */
64
+ request: SyncPrebuiltRequest;
65
+ /** Defaults to `X-Partition-Id`. */
66
+ partitionHeader?: string;
67
+ }
68
+
69
+ export type SyncTransportOptions = SyncFetchTransportOptions | SyncPrebuiltRequestTransportOptions;
70
+
71
+ export interface SyncRequestOptions {
72
+ method?: string;
73
+ query?: Record<string, string>;
74
+ body?: unknown;
75
+ /** Partition the caller expects; sent as the partition header when present. */
76
+ partitionId?: string | null;
77
+ signal?: AbortSignal;
78
+ }
79
+
80
+ const DEFAULT_PARTITION_HEADER = 'X-Partition-Id';
81
+ const DEFAULT_PARTITION_MISMATCH_CODE = 'partition_identity_mismatch';
82
+ const RETRYABLE_STATUSES = new Set([408, 429]);
83
+ export const DEFAULT_RETRY_AFTER_FALLBACK_MS = 60_000;
84
+
85
+ interface ServerErrorBody {
86
+ code?: unknown;
87
+ message?: unknown;
88
+ }
89
+
90
+ function isRetryableStatus(status: number): boolean {
91
+ return RETRYABLE_STATUSES.has(status) || status >= 500;
92
+ }
93
+
94
+ export interface ParseRetryAfterOptions {
95
+ /** Unix time in milliseconds. Defaults to `Date.now()`. */
96
+ now?: number;
97
+ /** Milliseconds added to `now` for missing, invalid, negative, or past values. */
98
+ fallbackMs?: number;
99
+ }
100
+
101
+ /** Resolves an HTTP `Retry-After` delta or date to an ISO timestamp. */
102
+ export function parseRetryAfter(
103
+ value: string | null | undefined,
104
+ options: ParseRetryAfterOptions = {},
105
+ ): string {
106
+ const now = options.now ?? Date.now();
107
+ const fallbackMs = options.fallbackMs ?? DEFAULT_RETRY_AFTER_FALLBACK_MS;
108
+ if (!Number.isFinite(now)) throw new RangeError('now must be finite');
109
+ if (!Number.isFinite(fallbackMs) || fallbackMs < 0) {
110
+ throw new RangeError('retryAfterFallbackMs must be finite and non-negative');
111
+ }
112
+
113
+ const fallbackAt = now + fallbackMs;
114
+ const trimmed = value?.trim() ?? '';
115
+ let candidate = Number.NaN;
116
+ if (/^\d+$/.test(trimmed)) {
117
+ candidate = now + Number(trimmed) * 1_000;
118
+ } else if (trimmed.length > 0) {
119
+ candidate = Date.parse(trimmed);
120
+ }
121
+ return toIsoTimestamp(
122
+ isSupportedTimestamp(candidate) && candidate >= now ? candidate : fallbackAt,
123
+ );
124
+ }
125
+
126
+ function isSupportedTimestamp(milliseconds: number): boolean {
127
+ return Number.isFinite(milliseconds) && !Number.isNaN(new Date(milliseconds).getTime());
128
+ }
129
+
130
+ function toIsoTimestamp(milliseconds: number): string {
131
+ try {
132
+ return new Date(milliseconds).toISOString();
133
+ } catch {
134
+ throw new RangeError('resolved Retry-After time is outside the supported date range');
135
+ }
136
+ }
137
+
138
+ export type SyncCursorComparator<TCursor> = (left: TCursor, right: TCursor) => number;
139
+
140
+ export interface PullPagePosition<TCursor> {
141
+ nextCursor: TCursor;
142
+ hasMore: boolean;
143
+ }
144
+
145
+ /** Checks cursor monotonicity and pagination progress, then returns the page. */
146
+ export function validatePullPage<TCursor, TPage extends PullPagePosition<TCursor>>(
147
+ currentCursor: TCursor,
148
+ page: TPage,
149
+ compare: SyncCursorComparator<TCursor>,
150
+ ): TPage {
151
+ const order = compare(page.nextCursor, currentCursor);
152
+ if (!Number.isFinite(order)) {
153
+ throw new SyncPayloadCompatibilityError('The pull cursor comparison was not finite.');
154
+ }
155
+ if (order < 0) {
156
+ throw new SyncPayloadCompatibilityError('The pull cursor moved backwards.');
157
+ }
158
+ if (page.hasMore && order === 0) {
159
+ throw new SyncPayloadCompatibilityError('The pull page did not advance its cursor.');
160
+ }
161
+ return page;
162
+ }
163
+
164
+ export interface CursorCommitOptions<TCursor, TResult> {
165
+ nextCursor: TCursor;
166
+ transaction: () => Promise<TResult>;
167
+ commitCursor: (cursor: TCursor) => Promise<void> | void;
168
+ }
169
+
170
+ /** Commits a pull cursor only after the local transaction succeeds. */
171
+ export async function commitCursorAfterLocalTransaction<TCursor, TResult>({
172
+ nextCursor,
173
+ transaction,
174
+ commitCursor,
175
+ }: CursorCommitOptions<TCursor, TResult>): Promise<TResult> {
176
+ let result: TResult;
177
+ try {
178
+ result = await transaction();
179
+ } catch (error) {
180
+ if (error instanceof SyncLocalApplyError) throw error;
181
+ throw new SyncLocalApplyError('The local pull transaction failed.', error);
182
+ }
183
+ try {
184
+ await commitCursor(nextCursor);
185
+ } catch (error) {
186
+ if (error instanceof SyncLocalApplyError) throw error;
187
+ throw new SyncLocalApplyError('The pull cursor could not be committed.', error);
188
+ }
189
+ return result;
190
+ }
191
+
192
+ function parseErrorBody(body: string): ServerErrorBody {
193
+ try {
194
+ const decoded: ServerErrorBody | null = JSON.parse(body) as ServerErrorBody | null;
195
+ return typeof decoded === 'object' && decoded !== null ? decoded : {};
196
+ } catch {
197
+ return {};
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Sync request plumbing for either a raw fetch or a product API client.
203
+ *
204
+ * Endpoint paths, request bodies, and response shapes stay product-defined:
205
+ * callers name the path and the response type on every call.
206
+ */
207
+ export class SyncTransport {
208
+ private readonly baseUrl: string | null;
209
+ private readonly partitionHeader: string;
210
+ private readonly partitionMismatchCode: string | null;
211
+ private readonly retryAfterFallbackMs: number;
212
+ private readonly now: () => number;
213
+
214
+ constructor(private readonly options: SyncTransportOptions) {
215
+ this.baseUrl = 'fetch' in options ? options.baseUrl.replace(/\/+$/, '') : null;
216
+ this.partitionHeader = options.partitionHeader ?? DEFAULT_PARTITION_HEADER;
217
+ this.partitionMismatchCode =
218
+ 'fetch' in options
219
+ ? (options.partitionMismatchCode ?? DEFAULT_PARTITION_MISMATCH_CODE)
220
+ : null;
221
+ this.retryAfterFallbackMs =
222
+ 'fetch' in options
223
+ ? (options.retryAfterFallbackMs ?? DEFAULT_RETRY_AFTER_FALLBACK_MS)
224
+ : DEFAULT_RETRY_AFTER_FALLBACK_MS;
225
+ this.now = 'fetch' in options ? (options.now ?? Date.now) : Date.now;
226
+ if (!Number.isFinite(this.retryAfterFallbackMs) || this.retryAfterFallbackMs < 0) {
227
+ throw new RangeError('retryAfterFallbackMs must be finite and non-negative');
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Sends one sync request and decodes its JSON response.
233
+ *
234
+ * The fetch-backed variant decodes JSON and maps failures to
235
+ * {@link SyncTransportError}. The prebuilt-request variant delegates response
236
+ * decoding and failure policy to that request function.
237
+ */
238
+ async request<T>(path: string, options: SyncRequestOptions = {}): Promise<T> {
239
+ if ('request' in this.options) {
240
+ return this.options.request<T>(this.path(path, options.query), this.init(options, {}));
241
+ }
242
+ const response = await this.send(path, options);
243
+ const body = await this.readBody(response);
244
+ if (!response.ok) {
245
+ throw this.asTransportError(response.status, body, response.headers?.get('Retry-After'));
246
+ }
247
+ return this.decode(body) as T;
248
+ }
249
+
250
+ private async send(path: string, options: SyncRequestOptions): Promise<SyncFetchResponse> {
251
+ if (!('fetch' in this.options)) {
252
+ throw new Error('unreachable request-backed transport');
253
+ }
254
+ const headers: Record<string, string> = { ...(await this.options.authHeaders()) };
255
+ const init = this.init(options, headers);
256
+ try {
257
+ return await this.options.fetch(this.url(path, options.query), init);
258
+ } catch (error) {
259
+ throw new SyncNetworkError(errorMessage(error), error);
260
+ }
261
+ }
262
+
263
+ private init(
264
+ options: SyncRequestOptions,
265
+ initialHeaders: Record<string, string>,
266
+ ): SyncRequestInit {
267
+ const headers = { ...initialHeaders };
268
+ if (options.body !== undefined) {
269
+ headers['Content-Type'] = 'application/json';
270
+ }
271
+ if (options.partitionId != null) {
272
+ headers[this.partitionHeader] = options.partitionId;
273
+ }
274
+ const init: SyncRequestInit = { headers };
275
+ if (options.method !== undefined) {
276
+ init.method = options.method;
277
+ }
278
+ if (options.body !== undefined) {
279
+ init.body = JSON.stringify(options.body);
280
+ }
281
+ if (options.signal !== undefined) {
282
+ init.signal = options.signal;
283
+ }
284
+ return init;
285
+ }
286
+
287
+ private async readBody(response: SyncFetchResponse): Promise<string> {
288
+ try {
289
+ return await response.text();
290
+ } catch (error) {
291
+ throw new SyncNetworkError(errorMessage(error), error);
292
+ }
293
+ }
294
+
295
+ private decode(body: string): unknown {
296
+ if (body.length === 0) {
297
+ return undefined;
298
+ }
299
+ try {
300
+ return JSON.parse(body);
301
+ } catch (error) {
302
+ throw new SyncPayloadCompatibilityError('The sync response was not valid JSON.', error);
303
+ }
304
+ }
305
+
306
+ private url(path: string, query: Record<string, string> | undefined): string {
307
+ return `${this.baseUrl ?? ''}${this.path(path, query)}`;
308
+ }
309
+
310
+ private path(path: string, query: Record<string, string> | undefined): string {
311
+ const suffix = query ? `?${new URLSearchParams(query).toString()}` : '';
312
+ return `${path}${suffix}`;
313
+ }
314
+
315
+ private asTransportError(
316
+ status: number,
317
+ body: string,
318
+ retryAfter: string | null | undefined,
319
+ ): SyncTransportError {
320
+ const decoded = parseErrorBody(body);
321
+ const message =
322
+ typeof decoded.message === 'string' && decoded.message.length > 0
323
+ ? decoded.message
324
+ : `sync request failed with status ${String(status)}`;
325
+ if (this.partitionMismatchCode !== null && decoded.code === this.partitionMismatchCode) {
326
+ return new SyncPartitionMismatchError(message);
327
+ }
328
+ if (status === 401) {
329
+ return new SyncAuthError(message);
330
+ }
331
+ if (status === 429) {
332
+ const retryAt = parseRetryAfter(retryAfter, {
333
+ now: this.now(),
334
+ fallbackMs: this.retryAfterFallbackMs,
335
+ });
336
+ return new SyncRateLimitError(message, retryAt, retryAfter ?? null);
337
+ }
338
+ return new SyncServerError(message, isRetryableStatus(status));
339
+ }
340
+ }
341
+
342
+ function errorMessage(error: unknown): string {
343
+ return error instanceof Error ? error.message : String(error);
344
+ }