@miurajs/miura-data-flow 0.0.1

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,351 @@
1
+ import { DataProvider, ProviderFactory } from './provider';
2
+
3
+ // ── Error type ────────────────────────────────────────────────────────────────
4
+
5
+ export class RestError extends Error {
6
+ constructor(
7
+ public readonly status: number,
8
+ public readonly statusText: string,
9
+ public readonly body: unknown,
10
+ public readonly url: string,
11
+ ) {
12
+ super(`[${status}] ${statusText} — ${url}`);
13
+ this.name = 'RestError';
14
+ }
15
+ }
16
+
17
+ // ── Interceptors ──────────────────────────────────────────────────────────────
18
+
19
+ export type RequestInterceptor = (
20
+ url: string,
21
+ init: RequestInit,
22
+ ) => RequestInit | Promise<RequestInit>;
23
+
24
+ export type ResponseInterceptor = (
25
+ response: Response,
26
+ url: string,
27
+ ) => Response | Promise<Response>;
28
+
29
+ // ── Pagination ────────────────────────────────────────────────────────────────
30
+
31
+ export interface PaginationConfig {
32
+ /** Strategy for adding pagination params to the request */
33
+ strategy: 'query' | 'header' | 'cursor';
34
+ /** Query param name for page number (query strategy) */
35
+ pageParam?: string;
36
+ /** Query param name for page size (query strategy) */
37
+ pageSizeParam?: string;
38
+ /** Default page size */
39
+ pageSize?: number;
40
+ /** Extract total count from response (used to determine if more pages exist) */
41
+ totalExtractor?: (response: Response, body: unknown) => number;
42
+ /** Extract next cursor from response body (cursor strategy) */
43
+ cursorExtractor?: (body: unknown) => string | null;
44
+ }
45
+
46
+ export interface PageResult<T> {
47
+ data: T[];
48
+ total: number;
49
+ page: number;
50
+ pageSize: number;
51
+ hasMore: boolean;
52
+ nextCursor?: string | null;
53
+ }
54
+
55
+ // ── Cache ─────────────────────────────────────────────────────────────────────
56
+
57
+ interface CacheEntry<T> {
58
+ data: T;
59
+ expiresAt: number;
60
+ }
61
+
62
+ // ── Provider options ──────────────────────────────────────────────────────────
63
+
64
+ export interface RetryConfig {
65
+ /** Maximum number of retry attempts (default: 3) */
66
+ maxAttempts: number;
67
+ /** Initial delay in ms before first retry (default: 300) */
68
+ initialDelay: number;
69
+ /** Multiplier applied to delay on each subsequent attempt (default: 2) */
70
+ backoffFactor: number;
71
+ /** HTTP status codes that should trigger a retry (default: [429, 500, 502, 503, 504]) */
72
+ retryOn: number[];
73
+ }
74
+
75
+ export interface RestProviderOptions {
76
+ /** Base URL — path segments are appended to this */
77
+ baseUrl: string;
78
+ /** Default headers merged into every request */
79
+ headers?: Record<string, string>;
80
+ /** Request timeout in ms (default: 30_000) */
81
+ timeout?: number;
82
+ /** Retry configuration */
83
+ retry?: Partial<RetryConfig>;
84
+ /** Response cache TTL in ms — set to 0 to disable (default: 0) */
85
+ cacheTtl?: number;
86
+ /** Pagination defaults */
87
+ pagination?: PaginationConfig;
88
+ /** Called before every request — use to inject auth headers, sign requests, etc. */
89
+ requestInterceptors?: RequestInterceptor[];
90
+ /** Called after every successful response — use to unwrap envelopes, normalise shapes, etc. */
91
+ responseInterceptors?: ResponseInterceptor[];
92
+ }
93
+
94
+ // ── Query/mutation option shapes ──────────────────────────────────────────────
95
+
96
+ export interface QueryOptions {
97
+ endpoint: string;
98
+ params?: Record<string, string | number | boolean>;
99
+ headers?: Record<string, string>;
100
+ signal?: AbortSignal;
101
+ }
102
+
103
+ export interface GetOptions {
104
+ endpoint: string;
105
+ headers?: Record<string, string>;
106
+ signal?: AbortSignal;
107
+ }
108
+
109
+ export interface MutateOptions<T> {
110
+ endpoint: string;
111
+ data: T;
112
+ method?: 'POST' | 'PUT' | 'PATCH';
113
+ headers?: Record<string, string>;
114
+ signal?: AbortSignal;
115
+ }
116
+
117
+ export interface PaginatedQueryOptions extends QueryOptions {
118
+ page?: number;
119
+ cursor?: string;
120
+ }
121
+
122
+ // ── Implementation ────────────────────────────────────────────────────────────
123
+
124
+ class RestProvider<T> implements DataProvider<T> {
125
+ private readonly retryConfig: RetryConfig;
126
+ private readonly cache = new Map<string, CacheEntry<unknown>>();
127
+
128
+ constructor(private readonly options: RestProviderOptions) {
129
+ this.retryConfig = {
130
+ maxAttempts: 3,
131
+ initialDelay: 300,
132
+ backoffFactor: 2,
133
+ retryOn: [429, 500, 502, 503, 504],
134
+ ...options.retry,
135
+ };
136
+ }
137
+
138
+ // ── Public API ─────────────────────────────────────────────────
139
+
140
+ async query(options: QueryOptions): Promise<T[]> {
141
+ const url = this.buildUrl(options.endpoint, options.params);
142
+ const cached = this.getCache<T[]>(url);
143
+ if (cached) return cached;
144
+
145
+ const response = await this.request(url, { method: 'GET', headers: this.mergeHeaders(options.headers) }, options.signal);
146
+ const data: T[] = await response.json();
147
+ this.setCache(url, data);
148
+ return data;
149
+ }
150
+
151
+ async get(id: string, options: GetOptions): Promise<T> {
152
+ const url = this.buildUrl(`${options.endpoint}/${id}`);
153
+ const cached = this.getCache<T>(url);
154
+ if (cached) return cached;
155
+
156
+ const response = await this.request(url, { method: 'GET', headers: this.mergeHeaders(options.headers) }, options.signal);
157
+ const data: T = await response.json();
158
+ this.setCache(url, data);
159
+ return data;
160
+ }
161
+
162
+ async mutate(options: MutateOptions<T>): Promise<T> {
163
+ const url = this.buildUrl(options.endpoint);
164
+ this.invalidateCachePrefix(options.endpoint);
165
+
166
+ const response = await this.request(
167
+ url,
168
+ {
169
+ method: options.method ?? 'POST',
170
+ headers: this.mergeHeaders(options.headers),
171
+ body: JSON.stringify(options.data),
172
+ },
173
+ options.signal,
174
+ );
175
+ return response.json();
176
+ }
177
+
178
+ async put(id: string, data: T, options: GetOptions): Promise<T> {
179
+ return this.mutate({ endpoint: `${options.endpoint}/${id}`, data, method: 'PUT', headers: options.headers, signal: options.signal });
180
+ }
181
+
182
+ async delete(id: string, options: GetOptions): Promise<void> {
183
+ const url = this.buildUrl(`${options.endpoint}/${id}`);
184
+ this.invalidateCachePrefix(options.endpoint);
185
+ await this.request(url, { method: 'DELETE', headers: this.mergeHeaders(options.headers) }, options.signal);
186
+ }
187
+
188
+ async queryPage(options: PaginatedQueryOptions): Promise<PageResult<T>> {
189
+ const pagination = this.options.pagination;
190
+ const params: Record<string, string | number | boolean> = { ...options.params };
191
+
192
+ if (pagination?.strategy === 'query') {
193
+ const pageParam = pagination.pageParam ?? 'page';
194
+ const sizeParam = pagination.pageSizeParam ?? 'pageSize';
195
+ params[pageParam] = options.page ?? 1;
196
+ params[sizeParam] = pagination.pageSize ?? 20;
197
+ } else if (pagination?.strategy === 'cursor' && options.cursor) {
198
+ params['cursor'] = options.cursor;
199
+ }
200
+
201
+ const url = this.buildUrl(options.endpoint, params);
202
+ const response = await this.request(url, { method: 'GET', headers: this.mergeHeaders(options.headers) }, options.signal);
203
+ const body: T[] | { data: T[]; [k: string]: unknown } = await response.json();
204
+
205
+ const data: T[] = Array.isArray(body) ? body : (body.data as T[]);
206
+ const total = pagination?.totalExtractor?.(response, body) ?? data.length;
207
+ const pageSize = (pagination?.pageSize ?? 20);
208
+ const page = options.page ?? 1;
209
+ const nextCursor = pagination?.cursorExtractor?.(body) ?? null;
210
+
211
+ return {
212
+ data,
213
+ total,
214
+ page,
215
+ pageSize,
216
+ hasMore: nextCursor != null || page * pageSize < total,
217
+ nextCursor,
218
+ };
219
+ }
220
+
221
+ // ── Core fetch with interceptors, retry, and timeout ───────────
222
+
223
+ private async request(
224
+ url: string,
225
+ init: RequestInit,
226
+ signal?: AbortSignal,
227
+ ): Promise<Response> {
228
+ let resolvedInit = await this.applyRequestInterceptors(url, init);
229
+
230
+ const timeoutMs = this.options.timeout ?? 30_000;
231
+ const attempt = async (attemptsLeft: number, delay: number): Promise<Response> => {
232
+ const controller = new AbortController();
233
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
234
+
235
+ // Merge caller signal with our timeout signal
236
+ const combinedSignal = signal
237
+ ? this.mergeSignals(signal, controller.signal)
238
+ : controller.signal;
239
+
240
+ try {
241
+ const response = await fetch(url, { ...resolvedInit, signal: combinedSignal });
242
+ clearTimeout(timeoutId);
243
+
244
+ if (!response.ok && this.retryConfig.retryOn.includes(response.status) && attemptsLeft > 0) {
245
+ await this.sleep(delay);
246
+ return attempt(attemptsLeft - 1, delay * this.retryConfig.backoffFactor);
247
+ }
248
+
249
+ if (!response.ok) {
250
+ const body = await response.json().catch(() => null);
251
+ throw new RestError(response.status, response.statusText, body, url);
252
+ }
253
+
254
+ return this.applyResponseInterceptors(response, url);
255
+ } catch (err) {
256
+ clearTimeout(timeoutId);
257
+ if (err instanceof RestError) throw err;
258
+ // Network / abort error — retry if attempts remain
259
+ if (attemptsLeft > 0 && !(signal?.aborted)) {
260
+ await this.sleep(delay);
261
+ return attempt(attemptsLeft - 1, delay * this.retryConfig.backoffFactor);
262
+ }
263
+ throw err;
264
+ }
265
+ };
266
+
267
+ return attempt(this.retryConfig.maxAttempts - 1, this.retryConfig.initialDelay);
268
+ }
269
+
270
+ private async applyRequestInterceptors(url: string, init: RequestInit): Promise<RequestInit> {
271
+ let result = init;
272
+ for (const interceptor of this.options.requestInterceptors ?? []) {
273
+ result = await interceptor(url, result);
274
+ }
275
+ return result;
276
+ }
277
+
278
+ private async applyResponseInterceptors(response: Response, url: string): Promise<Response> {
279
+ let result = response;
280
+ for (const interceptor of this.options.responseInterceptors ?? []) {
281
+ result = await interceptor(result, url);
282
+ }
283
+ return result;
284
+ }
285
+
286
+ // ── Helpers ────────────────────────────────────────────────────
287
+
288
+ private buildUrl(path: string, params?: Record<string, string | number | boolean>): string {
289
+ const base = this.options.baseUrl.replace(/\/$/, '');
290
+ const normalised = path.startsWith('/') ? path : `/${path}`;
291
+ const url = `${base}${normalised}`;
292
+ if (!params || Object.keys(params).length === 0) return url;
293
+ const qs = new URLSearchParams(
294
+ Object.entries(params).map(([k, v]) => [k, String(v)]),
295
+ ).toString();
296
+ return `${url}?${qs}`;
297
+ }
298
+
299
+ private mergeHeaders(extra?: Record<string, string>): Record<string, string> {
300
+ return {
301
+ 'Content-Type': 'application/json',
302
+ ...this.options.headers,
303
+ ...extra,
304
+ };
305
+ }
306
+
307
+ private mergeSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
308
+ const controller = new AbortController();
309
+ const abort = () => controller.abort();
310
+ a.addEventListener('abort', abort, { once: true });
311
+ b.addEventListener('abort', abort, { once: true });
312
+ return controller.signal;
313
+ }
314
+
315
+ private sleep(ms: number): Promise<void> {
316
+ return new Promise(resolve => setTimeout(resolve, ms));
317
+ }
318
+
319
+ // ── Cache helpers ──────────────────────────────────────────────
320
+
321
+ private getCache<R>(key: string): R | null {
322
+ if (!this.options.cacheTtl) return null;
323
+ const entry = this.cache.get(key) as CacheEntry<R> | undefined;
324
+ if (!entry) return null;
325
+ if (Date.now() > entry.expiresAt) {
326
+ this.cache.delete(key);
327
+ return null;
328
+ }
329
+ return entry.data;
330
+ }
331
+
332
+ private setCache(key: string, data: unknown): void {
333
+ if (!this.options.cacheTtl) return;
334
+ this.cache.set(key, { data, expiresAt: Date.now() + this.options.cacheTtl });
335
+ }
336
+
337
+ private invalidateCachePrefix(prefix: string): void {
338
+ const normalised = this.buildUrl(prefix);
339
+ for (const key of this.cache.keys()) {
340
+ if (key.startsWith(normalised)) this.cache.delete(key);
341
+ }
342
+ }
343
+ }
344
+
345
+ // ── Factory ───────────────────────────────────────────────────────────────────
346
+
347
+ export class RestProviderFactory implements ProviderFactory {
348
+ create<T>(options: RestProviderOptions): DataProvider<T> {
349
+ return new RestProvider<T>(options);
350
+ }
351
+ }
@@ -0,0 +1,41 @@
1
+ import { DataProvider, ProviderFactory } from './provider';
2
+ import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; // Example
3
+
4
+ export interface S3ProviderOptions {
5
+ region: string;
6
+ bucket: string;
7
+ }
8
+
9
+ class S3Provider implements DataProvider<any> {
10
+ private client: S3Client;
11
+ private bucket: string;
12
+
13
+ constructor(options: S3ProviderOptions) {
14
+ this.client = new S3Client({ region: options.region });
15
+ this.bucket = options.bucket;
16
+ }
17
+
18
+ async get(key: string): Promise<any> {
19
+ const command = new GetObjectCommand({
20
+ Bucket: this.bucket,
21
+ Key: key,
22
+ });
23
+ const response = await this.client.send(command);
24
+ return response.Body?.transformToString();
25
+ }
26
+
27
+ async put(key: string, data: any): Promise<any> {
28
+ const command = new PutObjectCommand({
29
+ Bucket: this.bucket,
30
+ Key: key,
31
+ Body: data as any,
32
+ });
33
+ return this.client.send(command);
34
+ }
35
+ }
36
+
37
+ export class S3ProviderFactory implements ProviderFactory {
38
+ create<T>(options: S3ProviderOptions): DataProvider<T> {
39
+ return new S3Provider(options) as DataProvider<T>;
40
+ }
41
+ }
@@ -0,0 +1,45 @@
1
+ import { DataProvider, ProviderFactory } from './provider';
2
+ // A real implementation would use the Supabase SDK
3
+ // import { createClient } from '@supabase/supabase-js'
4
+
5
+ export interface SupabaseProviderOptions {
6
+ supabaseUrl: string;
7
+ supabaseKey: string;
8
+ }
9
+
10
+ class SupabaseProvider<T> implements DataProvider<T> {
11
+ // private supabase: any;
12
+
13
+ constructor(options: SupabaseProviderOptions) {
14
+ // this.supabase = createClient(options.supabaseUrl, options.supabaseKey);
15
+ console.log('SupabaseProvider initialized');
16
+ }
17
+
18
+ async query(options: { table: string; query?: string }): Promise<T[]> {
19
+ console.log(`[Supabase] Querying table "${options.table}"`);
20
+ // const { data, error } = await this.supabase
21
+ // .from(options.table)
22
+ // .select(options.query || '*');
23
+ // if (error) throw error;
24
+ // return data;
25
+ return Promise.resolve([] as T[]);
26
+ }
27
+
28
+ async get(id: string, options: { table: string }): Promise<T> {
29
+ console.log(`[Supabase] Getting id "${id}" from table "${options.table}"`);
30
+ // const { data, error } = await this.supabase
31
+ // .from(options.table)
32
+ // .select('*')
33
+ // .eq('id', id)
34
+ // .single();
35
+ // if (error) throw error;
36
+ // return data;
37
+ return Promise.resolve({} as T);
38
+ }
39
+ }
40
+
41
+ export class SupabaseProviderFactory implements ProviderFactory {
42
+ create<T>(options: SupabaseProviderOptions): DataProvider<T> {
43
+ return new SupabaseProvider<T>(options);
44
+ }
45
+ }
@@ -0,0 +1,54 @@
1
+ import { DataProvider, ProviderFactory } from './provider';
2
+
3
+ export interface WebSocketProviderOptions {
4
+ url: string;
5
+ protocols?: string | string[];
6
+ }
7
+
8
+ class WebSocketProvider<T> implements DataProvider<T> {
9
+ private ws: WebSocket;
10
+
11
+ constructor(options: WebSocketProviderOptions) {
12
+ this.ws = new WebSocket(options.url, options.protocols);
13
+ }
14
+
15
+ subscribe(options: { event: string }, callback: (data: T) => void): () => void {
16
+ const handler = (event: MessageEvent) => {
17
+ try {
18
+ const message = JSON.parse(event.data);
19
+ // Assuming a message format of { event: 'name', payload: ... }
20
+ if (message.event === options.event) {
21
+ callback(message.payload);
22
+ }
23
+ } catch (e) {
24
+ console.error('Error parsing WebSocket message', e);
25
+ }
26
+ };
27
+
28
+ this.ws.addEventListener('message', handler);
29
+
30
+ // Return an unsubscribe function
31
+ return () => {
32
+ this.ws.removeEventListener('message', handler);
33
+ };
34
+ }
35
+
36
+ // A method to send data back to the server
37
+ send(event: string, payload: any) {
38
+ if (this.ws.readyState === WebSocket.OPEN) {
39
+ this.ws.send(JSON.stringify({ event, payload }));
40
+ } else {
41
+ console.warn('WebSocket is not open. Cannot send message.');
42
+ }
43
+ }
44
+
45
+ close() {
46
+ this.ws.close();
47
+ }
48
+ }
49
+
50
+ export class WebSocketProviderFactory implements ProviderFactory {
51
+ create<T>(options: WebSocketProviderOptions): DataProvider<T> {
52
+ return new WebSocketProvider<T>(options);
53
+ }
54
+ }