@asaidimu/hestia 1.0.1 → 1.0.3

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/core/client.ts DELETED
@@ -1,408 +0,0 @@
1
- import {
2
- createNetworkClient,
3
- type NetworkClient,
4
- type ApiResponse,
5
- } from "@asaidimu/network-client";
6
- import { SystemError } from "@asaidimu/utils-error";
7
- import { parseErrorBody, toSystemError } from "./errors";
8
- import type { UserIdentity } from "../system/identity/types";
9
-
10
- export interface IdentityProvider {
11
- identity(): UserIdentity | null;
12
- token(key: "access" | "refresh"): string | null;
13
- setTokens(access: string, refresh: string): Promise<void>;
14
- setIdentity(id: UserIdentity | null): Promise<void>;
15
- clear(): Promise<void>;
16
- }
17
-
18
- export class HestiaResponse<T> {
19
- constructor(
20
- public readonly data: T,
21
- public readonly status: number,
22
- ) {}
23
- }
24
-
25
- type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE" | "PUT";
26
- type BodyType = "json" | "form" | "text" | "blob" | "stream" | "auto";
27
- type ResponseType =
28
- | "json"
29
- | "text"
30
- | "blob"
31
- | "arrayBuffer"
32
- | "formData"
33
- | "auto";
34
-
35
- interface RequestOptions {
36
- headers?: Record<string, string>;
37
- responseType?: ResponseType;
38
- bodyType?: BodyType;
39
- signal?: AbortSignal;
40
- }
41
-
42
- // Handlers driving a live server-sent-events stream opened via
43
- // HestiaNetworkClient.openStream(). onMessage fires once per parsed SSE
44
- // "data:" payload (still a raw string — callers decide whether/how to
45
- // JSON.parse it); onOpen/onClose/onError report connection lifecycle.
46
- export interface StreamHandlers {
47
- onMessage: (data: string) => void;
48
- onError?: (err: Error) => void;
49
- onOpen?: () => void;
50
- onClose?: () => void;
51
- }
52
-
53
- export interface StreamOptions {
54
- headers?: Record<string, string>;
55
- signal?: AbortSignal;
56
- }
57
-
58
- export class HestiaNetworkClient {
59
- private raw: NetworkClient;
60
- private refreshing: Promise<void> | null = null;
61
- private refreshFailed = false;
62
-
63
- constructor(
64
- private baseUrl: string,
65
- private apiPrefix: string,
66
- private tokens: IdentityProvider,
67
- private onAuthStateChanged?: () => void,
68
- ) {
69
- this.raw = createNetworkClient({
70
- baseUrl,
71
- defaultResponseType: "json",
72
- defaultBodyType: "json",
73
- interceptors: {
74
- request: [
75
- (ctx) => {
76
- const access = this.tokens.token("access");
77
- if (access) {
78
- ctx.headers["Authorization"] = `Bearer ${access}`;
79
- }
80
- return ctx;
81
- },
82
- ],
83
- },
84
- });
85
- }
86
-
87
- base() {
88
- return this.baseUrl;
89
- }
90
-
91
- prefix(): string {
92
- return this.apiPrefix;
93
- }
94
-
95
- // Single entry-point for all path manipulation (prefix handling).
96
- // Returns the relative path that should be appended to the base URL.
97
- private canonicalPath(path: string): string {
98
- // 1. Strip any leading slashes so we work with a clean base (e.g., "api/api/system" or "/api/system")
99
- let cleanPath = path.replace(/^\/+/, "");
100
-
101
- if (this.apiPrefix) {
102
- // 2. Strip leading slashes from the prefix too, just to be safe and consistent
103
- const cleanPrefix = this.apiPrefix.replace(/^\/+/, "");
104
-
105
- // 3. If the path already starts with the prefix followed by a slash (or is exactly the prefix),
106
- // remove it so we don't double up.
107
- const prefixRegex = new RegExp(`^${cleanPrefix}/?`);
108
- cleanPath = cleanPath.replace(prefixRegex, "");
109
-
110
- // 4. Combine them cleanly
111
- return `${cleanPrefix}/${cleanPath}`;
112
- }
113
-
114
- return cleanPath;
115
- }
116
-
117
- async storeTokens(access: string, refresh?: string): Promise<void> {
118
- this.refreshFailed = false;
119
- await this.tokens.setTokens(access, refresh ?? "");
120
- }
121
-
122
- private async refreshToken(): Promise<void> {
123
- if (this.refreshing) return this.refreshing;
124
-
125
- this.refreshing = this.doRefresh();
126
- try {
127
- await this.refreshing;
128
- } finally {
129
- this.refreshing = null;
130
- this.onAuthStateChanged ? this.onAuthStateChanged() : undefined;
131
- }
132
- }
133
-
134
- private async doRefresh(): Promise<void> {
135
- const refresh = this.tokens.token("refresh");
136
- const body = refresh ? { refresh_token: refresh } : {};
137
- const res = await this.raw.patch<{
138
- data: { token: { access: string; refresh: string } };
139
- }>(this.canonicalPath("/system/auth/session"), body);
140
-
141
- if (!res.success || !res.data) {
142
- this.tokens.clear();
143
- this.refreshFailed = true;
144
- throw new SystemError({
145
- code: "AUTH-002-UNAUTH",
146
- message: "Token refresh failed",
147
- });
148
- }
149
-
150
- const { access, refresh: newRefresh } = res.data.data.token;
151
- this.refreshFailed = false;
152
- await this.tokens.setTokens(access, newRefresh);
153
- }
154
-
155
- private async request<T>(
156
- method: HttpMethod,
157
- path: string,
158
- body?: unknown,
159
- options?: RequestOptions,
160
- ): Promise<HestiaResponse<T>> {
161
- const fullPath = this.canonicalPath(path);
162
-
163
- const opts: any = {};
164
- if (options?.headers) opts.headers = options.headers;
165
- if (options?.responseType) opts.responseType = options.responseType;
166
- if (options?.bodyType) opts.bodyType = options.bodyType;
167
- if (options?.signal) opts.signal = options.signal;
168
-
169
- let res: ApiResponse<T>;
170
-
171
- if (method === "GET") {
172
- res = await this.raw.get<T>(fullPath, opts);
173
- } else {
174
- const bodyOpts = options?.bodyType
175
- ? { type: options.bodyType as BodyType }
176
- : undefined;
177
- res = (await (this.raw as any)[method.toLowerCase()](
178
- fullPath,
179
- body,
180
- opts,
181
- bodyOpts,
182
- )) as ApiResponse<T>;
183
- }
184
-
185
- if (res.success || res.status === 204) {
186
- return new HestiaResponse(res.data as T, res.status);
187
- }
188
-
189
- if (
190
- (res.status === 401 || res.status === 403) &&
191
- !path.includes("/system/auth/token") &&
192
- !path.includes("/system/auth/session")
193
- ) {
194
- // Issue B: API-key requests should never trigger a JWT refresh loop.
195
- if (options?.headers?.["X-API-Key"]) {
196
- throw new SystemError({
197
- code: "AUTH-003-APIKEY",
198
- message: "API key is invalid or expired",
199
- });
200
- }
201
-
202
- // If a previous refresh already failed, don’t loop — the tokens
203
- // (including any cookie fallback) are known to be dead.
204
- if (this.refreshFailed) {
205
- this.tokens.clear();
206
- this.onAuthStateChanged?.();
207
- throw new SystemError({
208
- code: "AUTH-002-UNAUTH",
209
- message: "Session expired",
210
- });
211
- }
212
-
213
- try {
214
- await this.refreshToken();
215
- } catch {
216
- this.tokens.clear();
217
- this.onAuthStateChanged?.();
218
- throw new SystemError({
219
- code: "AUTH-002-UNAUTH",
220
- message: "Session expired",
221
- });
222
- }
223
-
224
- // Issue A: Read the new access token and inject it explicitly so that
225
- // an async setTokens (e.g. IndexedDB write) cannot race the interceptor.
226
- const newAccess = this.tokens.token("access");
227
- const retryOpts: any = { ...opts };
228
- if (newAccess) {
229
- retryOpts.headers = {
230
- ...(opts.headers ?? {}),
231
- Authorization: `Bearer ${newAccess}`,
232
- };
233
- }
234
-
235
- if (method === "GET") {
236
- res = await this.raw.get<T>(fullPath, retryOpts);
237
- } else {
238
- const bodyOpts = options?.bodyType
239
- ? { type: options.bodyType as BodyType }
240
- : undefined;
241
- res = (await (this.raw as any)[method.toLowerCase()](
242
- fullPath,
243
- body,
244
- retryOpts,
245
- bodyOpts,
246
- )) as ApiResponse<T>;
247
- }
248
-
249
- if (res.success || res.status === 204) {
250
- return new HestiaResponse(res.data as T, res.status);
251
- }
252
- }
253
-
254
- const errorBody = res.raw ? await parseErrorBody(res.raw) : null;
255
- throw toSystemError(res, errorBody);
256
- }
257
-
258
- async get<T>(
259
- path: string,
260
- options?: RequestOptions,
261
- ): Promise<HestiaResponse<T>> {
262
- return this.request<T>("GET", path, undefined, options);
263
- }
264
-
265
- async post<T>(
266
- path: string,
267
- body?: unknown,
268
- options?: RequestOptions,
269
- ): Promise<HestiaResponse<T>> {
270
- return this.request<T>("POST", path, body, options);
271
- }
272
-
273
- async patch<T>(
274
- path: string,
275
- body?: unknown,
276
- options?: RequestOptions,
277
- ): Promise<HestiaResponse<T>> {
278
- return this.request<T>("PATCH", path, body, options);
279
- }
280
-
281
- async put<T>(
282
- path: string,
283
- body?: unknown,
284
- options?: RequestOptions,
285
- ): Promise<HestiaResponse<T>> {
286
- return this.request<T>("PUT", path, body, options);
287
- }
288
-
289
- async delete<T>(
290
- path: string,
291
- body?: unknown,
292
- options?: RequestOptions,
293
- ): Promise<HestiaResponse<T>> {
294
- return this.request<T>("DELETE", path, body, options);
295
- }
296
-
297
- // Opens an authenticated, long-lived GET request against a
298
- // "text/event-stream" endpoint (e.g. system:audit:log:stream) and parses
299
- // standard SSE framing ("data: ...\n\n") off the raw response body.
300
- //
301
- // Bypasses `raw` deliberately: the wrapped network-client's request/
302
- // response cycle is built around single JSON responses, not an
303
- // indefinitely-open ReadableStream, so this talks to `fetch` directly.
304
- // On a 401 it will attempt exactly one token refresh and reconnect once
305
- // before giving up — mirroring `request()`'s retry behavior, but capped
306
- // at a single attempt since a stream that keeps failing auth shouldn’t
307
- // loop indefinitely in the background.
308
- async openStream(
309
- path: string,
310
- handlers: StreamHandlers,
311
- options?: StreamOptions,
312
- ): Promise<void> {
313
- const attempt = async (isRetry: boolean): Promise<void> => {
314
- const access = this.tokens.token("access");
315
- const isApiKeyAuth = !!options?.headers?.["X-API-Key"];
316
- const headers: Record<string, string> = {
317
- Accept: "text/event-stream",
318
- ...(access && !isApiKeyAuth
319
- ? { Authorization: `Bearer ${access}` }
320
- : {}),
321
- ...(options?.headers ?? {}),
322
- };
323
-
324
- const url = `${this.baseUrl.replace(/\/+$/, "")}/${this.canonicalPath(path)}`;
325
-
326
- let response: Response;
327
- try {
328
- response = await fetch(url, {
329
- method: "GET",
330
- headers,
331
- signal: options?.signal,
332
- });
333
- } catch (err) {
334
- if (err instanceof Error && err.name === "AbortError") {
335
- handlers.onClose?.();
336
- return;
337
- }
338
- handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
339
- return;
340
- }
341
-
342
- if (response.status === 401 && !isRetry && !isApiKeyAuth) {
343
- try {
344
- await this.refreshToken();
345
- } catch {
346
- this.tokens.clear();
347
- this.onAuthStateChanged?.();
348
- handlers.onError?.(
349
- new SystemError({
350
- code: "AUTH-002-UNAUTH",
351
- message: "Session expired",
352
- }),
353
- );
354
- return;
355
- }
356
- return attempt(true);
357
- }
358
-
359
- if (!response.ok || !response.body) {
360
- handlers.onError?.(
361
- new Error(`Stream request failed with status ${response.status}`),
362
- );
363
- return;
364
- }
365
-
366
- handlers.onOpen?.();
367
-
368
- const reader = response.body.getReader();
369
- const decoder = new TextDecoder();
370
- let buffer = "";
371
-
372
- try {
373
- while (true) {
374
- const { done, value } = await reader.read();
375
- if (done) break;
376
- buffer += decoder.decode(value, { stream: true });
377
-
378
- let separatorIndex = buffer.indexOf("\n\n");
379
- while (separatorIndex !== -1) {
380
- const rawEvent = buffer.slice(0, separatorIndex);
381
- buffer = buffer.slice(separatorIndex + 2);
382
-
383
- const dataLines = rawEvent
384
- .split("\n")
385
- .filter((line) => line.startsWith("data:"))
386
- .map((line) => line.slice(5).trim());
387
-
388
- if (dataLines.length > 0) {
389
- handlers.onMessage(dataLines.join("\n"));
390
- }
391
-
392
- separatorIndex = buffer.indexOf("\n\n");
393
- }
394
- }
395
- } catch (err) {
396
- if (!(err instanceof Error && err.name === "AbortError")) {
397
- handlers.onError?.(
398
- err instanceof Error ? err : new Error(String(err)),
399
- );
400
- }
401
- } finally {
402
- handlers.onClose?.();
403
- }
404
- };
405
-
406
- return attempt(false);
407
- }
408
- }
@@ -1,145 +0,0 @@
1
- import type { QueryDSL } from "@asaidimu/query";
2
- import { ReactiveDataStore } from "@asaidimu/utils-store";
3
- import { HestiaNetworkClient } from "./client";
4
- import { createPagedController, type PageOptions } from "./pager";
5
- import type {
6
- Document,
7
- Page,
8
- PagedData,
9
- PaginationInfo,
10
- StoreEvent,
11
- } from "./types";
12
- import type { DocumentStore } from "./types";
13
-
14
- interface ServerEnvelope<T extends Record<string, any>> {
15
- data: Document<T>[];
16
- metadata?: { page?: PaginationInfo };
17
- }
18
-
19
- interface SingleEnvelope<T extends Record<string, any>> {
20
- data: Document<T>;
21
- }
22
-
23
- export class HestiaCollection<T extends Record<string, any>> implements DocumentStore<T, Record<string, unknown>, string, Record<string, unknown>, Record<string, unknown>, string, string, Record<string, unknown>> {
24
- private pagerOptions: PageOptions<T> = {};
25
- private pager: PagedData<T>;
26
-
27
- constructor(
28
- private client: HestiaNetworkClient,
29
- private collectionName: string,
30
- private defaultLimit: number = 50,
31
- ) {
32
- this.pager = createPagedController<T>(
33
- collectionName,
34
- new ReactiveDataStore<any>({}),
35
- this.pagerOptions,
36
- (query) => this.find(query as any),
37
- );
38
- }
39
-
40
- name() {
41
- return this.collectionName;
42
- }
43
-
44
- private get queryPath(): string {
45
- return `/system/collections/document/${encodeURIComponent(this.collectionName)}/query`;
46
- }
47
-
48
- private get documentsPath(): string {
49
- return `/system/collections/document/${encodeURIComponent(this.collectionName)}`;
50
- }
51
-
52
- private documentPath(id: string): string {
53
- return `${this.documentsPath}/${encodeURIComponent(id)}`;
54
- }
55
-
56
- async find(query?: Record<string, unknown>): Promise<Page<T>> {
57
- const res = await this.client.post<ServerEnvelope<T>>(
58
- this.queryPath,
59
- query ?? {},
60
- );
61
-
62
- const items = res.data?.data ?? [];
63
- const pageMeta = res.data?.metadata?.page ?? {
64
- number: 1,
65
- size: items.length,
66
- count: items.length,
67
- total: items.length,
68
- pages: 1,
69
- };
70
-
71
- return { data: items, loading: false, page: pageMeta, error: null };
72
- }
73
-
74
- async read(id: string): Promise<Document<T> | undefined> {
75
- try {
76
- const res = await this.client.get<{ data: Document<T> }>(
77
- this.documentPath(id),
78
- );
79
- return res.data?.data;
80
- } catch (err: any) {
81
- if (err?.code === "SYNC-001-NF" || err?.code === "NOT_FOUND")
82
- return undefined;
83
- throw err;
84
- }
85
- }
86
-
87
- async create(props: { data: Partial<T> }): Promise<Document<T> | undefined> {
88
- const res = await this.client.post<SingleEnvelope<T>>(
89
- this.documentsPath,
90
- props.data,
91
- );
92
- return res.data!.data;
93
- }
94
-
95
- async update(props: { data: Partial<T>; options?: string }): Promise<Document<T> | undefined> {
96
- const id = props.options!;
97
- const res = await this.client.patch<SingleEnvelope<T>>(
98
- this.documentPath(id),
99
- props.data,
100
- );
101
- return res.data!.data;
102
- }
103
-
104
- async delete(id: string): Promise<void> {
105
- await this.client.delete(this.documentPath(id));
106
- }
107
-
108
- async list(options?: Record<string, unknown>): Promise<Page<T>> {
109
- return this.find(
110
- options ?? { pagination: { type: "offset", offset: 0, limit: this.defaultLimit } },
111
- );
112
- }
113
-
114
- async upload(_props: { file: File }): Promise<Document<T> | undefined> {
115
- throw new Error("Upload not supported for collections");
116
- }
117
-
118
- async subscribe(
119
- _scope: string,
120
- _callback: (event: StoreEvent) => void,
121
- ): Promise<() => void> {
122
- throw new Error("Subscription not implemented for dynamic collections");
123
- }
124
-
125
- async notify(_event: StoreEvent): Promise<void> {
126
- throw new Error("Notify not implemented for dynamic collections");
127
- }
128
-
129
- stream(
130
- _options: Record<string, unknown>,
131
- _onStreamChange: () => void,
132
- ): {
133
- stream: () => AsyncIterable<Document<T>>;
134
- cancel: () => void;
135
- status: () => "active" | "cancelled" | "completed";
136
- } {
137
- throw new Error("Stream not supported for collections");
138
- }
139
-
140
- page(_options?: Record<string, unknown>): PagedData<T> {
141
- return this.pager;
142
- }
143
- }
144
-
145
- export type { ServerEnvelope, SingleEnvelope };
package/core/errors.ts DELETED
@@ -1,55 +0,0 @@
1
- import type {
2
- ApiResponse,
3
- } from "@asaidimu/network-client"
4
- import { SystemError, Errors, type Issue } from "@asaidimu/utils-error"
5
-
6
- interface ServerErrorBody {
7
- code?: string
8
- message?: string
9
- details?: { issues?: Issue[] }
10
- }
11
-
12
- export async function parseErrorBody(raw: Response): Promise<ServerErrorBody | null> {
13
- try {
14
- const body = await raw.clone().json() as { error?: ServerErrorBody }
15
- return body?.error ?? null
16
- } catch {
17
- return null
18
- }
19
- }
20
-
21
- export function toSystemError(response: ApiResponse<unknown>, body: ServerErrorBody | null): SystemError {
22
- if (body) {
23
- return new SystemError({
24
- code: body.code ?? "UNKNOWN",
25
- message: body.message ?? "Unknown error",
26
- issues: body.details?.issues,
27
- })
28
- }
29
-
30
- const rawMsg = response.error?.message
31
- if (rawMsg !== null && rawMsg !== undefined && typeof rawMsg === "object") {
32
- const obj = rawMsg as Record<string, unknown>
33
- return new SystemError({
34
- code: (obj.code as string) ?? "UNKNOWN",
35
- message: (obj.message as string) ?? "Unknown error",
36
- })
37
- }
38
-
39
- return new SystemError({
40
- code: "UNKNOWN",
41
- message: (rawMsg as string) ?? "Unknown error",
42
- })
43
- }
44
-
45
- export function notFound(path?: string): SystemError {
46
- return Errors.notFound(path)
47
- }
48
-
49
- export function permissionDenied(operation?: string): SystemError {
50
- return Errors.permissionDenied(operation)
51
- }
52
-
53
- export function internalError(cause?: unknown): SystemError {
54
- return Errors.internalError(cause)
55
- }