@asaidimu/hestia 1.0.0

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 ADDED
@@ -0,0 +1,247 @@
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 { 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
+ export class HestiaNetworkClient {
43
+ private raw: NetworkClient;
44
+ private refreshing: Promise<void> | null = null;
45
+ private refreshFailed = false;
46
+
47
+ constructor(
48
+ private baseUrl: string,
49
+ private tokens: IdentityProvider,
50
+ private onAuthStateChanged?: () => void,
51
+ ) {
52
+ this.raw = createNetworkClient({
53
+ baseUrl,
54
+ defaultResponseType: "json",
55
+ defaultBodyType: "json",
56
+ interceptors: {
57
+ request: [
58
+ (ctx) => {
59
+ const access = this.tokens.token("access");
60
+ if (access) {
61
+ ctx.headers["Authorization"] = `Bearer ${access}`;
62
+ }
63
+ return ctx;
64
+ },
65
+ ],
66
+ },
67
+ });
68
+ }
69
+
70
+ base() {
71
+ return this.baseUrl;
72
+ }
73
+ async storeTokens(access: string, refresh?: string): Promise<void> {
74
+ this.refreshFailed = false;
75
+ await this.tokens.setTokens(access, refresh ?? "");
76
+ }
77
+
78
+ private async refreshToken(): Promise<void> {
79
+ if (this.refreshing) return this.refreshing;
80
+
81
+ this.refreshing = this.doRefresh();
82
+ try {
83
+ await this.refreshing;
84
+ } finally {
85
+ this.refreshing = null;
86
+ this.onAuthStateChanged ? this.onAuthStateChanged() : undefined;
87
+ }
88
+ }
89
+
90
+ private async doRefresh(): Promise<void> {
91
+ const refresh = this.tokens.token("refresh");
92
+ const body = refresh ? { refresh_token: refresh } : {};
93
+ const res = await this.raw.patch<{
94
+ data: { token: { access: string; refresh: string } };
95
+ }>("/system/auth/session", body);
96
+
97
+ if (!res.success || !res.data) {
98
+ this.tokens.clear();
99
+ this.refreshFailed = true;
100
+ throw new SystemError({
101
+ code: "AUTH-002-UNAUTH",
102
+ message: "Token refresh failed",
103
+ });
104
+ }
105
+
106
+ const { access, refresh: newRefresh } = res.data.data.token;
107
+ this.refreshFailed = false;
108
+ await this.tokens.setTokens(access, newRefresh);
109
+ }
110
+
111
+ private async request<T>(
112
+ method: HttpMethod,
113
+ path: string,
114
+ body?: unknown,
115
+ options?: RequestOptions,
116
+ ): Promise<HestiaResponse<T>> {
117
+ const opts: any = {};
118
+ if (options?.headers) opts.headers = options.headers;
119
+ if (options?.responseType) opts.responseType = options.responseType;
120
+ if (options?.bodyType) opts.bodyType = options.bodyType;
121
+ if (options?.signal) opts.signal = options.signal;
122
+
123
+ let res: ApiResponse<T>;
124
+
125
+ if (method === "GET") {
126
+ res = await this.raw.get<T>(path, opts);
127
+ } else {
128
+ const bodyOpts = options?.bodyType
129
+ ? { type: options.bodyType as BodyType }
130
+ : undefined;
131
+ res = (await (this.raw as any)[method.toLowerCase()](
132
+ path,
133
+ body,
134
+ opts,
135
+ bodyOpts,
136
+ )) as ApiResponse<T>;
137
+ }
138
+
139
+ if (res.success || res.status === 204) {
140
+ return new HestiaResponse(res.data as T, res.status);
141
+ }
142
+
143
+ if (
144
+ (res.status === 401 || res.status === 403) &&
145
+ !path.includes("/system/auth/token") &&
146
+ !path.includes("/system/auth/session")
147
+ ) {
148
+ // Issue B: API-key requests should never trigger a JWT refresh loop.
149
+ if (options?.headers?.["X-API-Key"]) {
150
+ throw new SystemError({
151
+ code: "AUTH-003-APIKEY",
152
+ message: "API key is invalid or expired",
153
+ });
154
+ }
155
+
156
+ // If a previous refresh already failed, don't loop — the tokens
157
+ // (including any cookie fallback) are known to be dead.
158
+ if (this.refreshFailed) {
159
+ this.tokens.clear();
160
+ this.onAuthStateChanged?.();
161
+ throw new SystemError({
162
+ code: "AUTH-002-UNAUTH",
163
+ message: "Session expired",
164
+ });
165
+ }
166
+
167
+ try {
168
+ await this.refreshToken();
169
+ } catch {
170
+ this.tokens.clear();
171
+ this.onAuthStateChanged?.();
172
+ throw new SystemError({
173
+ code: "AUTH-002-UNAUTH",
174
+ message: "Session expired",
175
+ });
176
+ }
177
+
178
+ // Issue A: Read the new access token and inject it explicitly so that
179
+ // an async setTokens (e.g. IndexedDB write) cannot race the interceptor.
180
+ const newAccess = this.tokens.token("access");
181
+ const retryOpts: any = { ...opts };
182
+ if (newAccess) {
183
+ retryOpts.headers = { ...(opts.headers ?? {}), Authorization: `Bearer ${newAccess}` };
184
+ }
185
+
186
+ if (method === "GET") {
187
+ res = await this.raw.get<T>(path, retryOpts);
188
+ } else {
189
+ const bodyOpts = options?.bodyType
190
+ ? { type: options.bodyType as BodyType }
191
+ : undefined;
192
+ res = (await (this.raw as any)[method.toLowerCase()](
193
+ path,
194
+ body,
195
+ retryOpts,
196
+ bodyOpts,
197
+ )) as ApiResponse<T>;
198
+ }
199
+
200
+ if (res.success || res.status === 204) {
201
+ return new HestiaResponse(res.data as T, res.status);
202
+ }
203
+ }
204
+
205
+ const errorBody = res.raw ? await parseErrorBody(res.raw) : null;
206
+ throw toSystemError(res, errorBody);
207
+ }
208
+
209
+ async get<T>(
210
+ path: string,
211
+ options?: RequestOptions,
212
+ ): Promise<HestiaResponse<T>> {
213
+ return this.request<T>("GET", path, undefined, options);
214
+ }
215
+
216
+ async post<T>(
217
+ path: string,
218
+ body?: unknown,
219
+ options?: RequestOptions,
220
+ ): Promise<HestiaResponse<T>> {
221
+ return this.request<T>("POST", path, body, options);
222
+ }
223
+
224
+ async patch<T>(
225
+ path: string,
226
+ body?: unknown,
227
+ options?: RequestOptions,
228
+ ): Promise<HestiaResponse<T>> {
229
+ return this.request<T>("PATCH", path, body, options);
230
+ }
231
+
232
+ async put<T>(
233
+ path: string,
234
+ body?: unknown,
235
+ options?: RequestOptions,
236
+ ): Promise<HestiaResponse<T>> {
237
+ return this.request<T>("PUT", path, body, options);
238
+ }
239
+
240
+ async delete<T>(
241
+ path: string,
242
+ body?: unknown,
243
+ options?: RequestOptions,
244
+ ): Promise<HestiaResponse<T>> {
245
+ return this.request<T>("DELETE", path, body, options);
246
+ }
247
+ }
@@ -0,0 +1,121 @@
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
+
13
+ interface ServerEnvelope<T extends Record<string, any>> {
14
+ data: Document<T>[];
15
+ metadata?: { page?: PaginationInfo };
16
+ }
17
+
18
+ interface SingleEnvelope<T extends Record<string, any>> {
19
+ data: Document<T>;
20
+ }
21
+
22
+ export class HestiaCollection<T extends Record<string, any>> {
23
+ private pagerOptions: PageOptions<T> = {};
24
+ private pager: PagedData<T>;
25
+
26
+ constructor(
27
+ private client: HestiaNetworkClient,
28
+ private collectionName: string,
29
+ ) {
30
+ this.pager = createPagedController<T>(
31
+ collectionName,
32
+ new ReactiveDataStore<any>({}),
33
+ this.pagerOptions,
34
+ (query) => this.find(query),
35
+ );
36
+ }
37
+
38
+ name() {
39
+ return this.collectionName;
40
+ }
41
+
42
+ private get queryPath(): string {
43
+ return `/system/collections/document/${encodeURIComponent(this.collectionName)}/query`;
44
+ }
45
+
46
+ private get documentsPath(): string {
47
+ return `/system/collections/document/${encodeURIComponent(this.collectionName)}`;
48
+ }
49
+
50
+ private documentPath(id: string): string {
51
+ return `${this.documentsPath}/${encodeURIComponent(id)}`;
52
+ }
53
+
54
+ async find(query?: QueryDSL<T>): Promise<Page<T>> {
55
+ const res = await this.client.post<ServerEnvelope<T>>(
56
+ this.queryPath,
57
+ query ?? {},
58
+ );
59
+
60
+ const items = res.data?.data ?? [];
61
+ const pageMeta = res.data?.metadata?.page ?? {
62
+ number: 1,
63
+ size: items.length,
64
+ count: items.length,
65
+ total: items.length,
66
+ pages: 1,
67
+ };
68
+
69
+ return { data: items, loading: false, page: pageMeta, error: null };
70
+ }
71
+
72
+ async read(id: string): Promise<Document<T> | undefined> {
73
+ try {
74
+ const res = await this.client.get<{ data: Document<T> }>(
75
+ this.documentPath(id),
76
+ );
77
+ return res.data?.data;
78
+ } catch (err: any) {
79
+ if (err?.code === "SYNC-001-NF" || err?.code === "NOT_FOUND")
80
+ return undefined;
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ async create(data: Partial<T>): Promise<Document<T>> {
86
+ const res = await this.client.post<SingleEnvelope<T>>(
87
+ this.documentsPath,
88
+ data,
89
+ );
90
+ return res.data!.data;
91
+ }
92
+
93
+ async update(id: string, data: Partial<T>): Promise<Document<T>> {
94
+ const res = await this.client.patch<SingleEnvelope<T>>(
95
+ this.documentPath(id),
96
+ data,
97
+ );
98
+ return res.data!.data;
99
+ }
100
+
101
+ async delete(id: string): Promise<void> {
102
+ await this.client.delete(this.documentPath(id));
103
+ }
104
+
105
+ async subscribe(
106
+ _scope: string,
107
+ _callback: (event: StoreEvent) => void,
108
+ ): Promise<() => void> {
109
+ throw new Error("Subscription not implemented for dynamic collections");
110
+ }
111
+
112
+ async notify(_event: StoreEvent): Promise<void> {
113
+ throw new Error("Notify not implemented for dynamic collections");
114
+ }
115
+
116
+ page(): PagedData<T> {
117
+ return this.pager;
118
+ }
119
+ }
120
+
121
+ export type { ServerEnvelope, SingleEnvelope };
package/core/errors.ts ADDED
@@ -0,0 +1,55 @@
1
+ import {
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
+ }
package/core/pager.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type { QueryDSL, QueryFilter, SortConfiguration } from "@asaidimu/query";
2
+ import { DELETE_SYMBOL, type DataStore } from "@asaidimu/utils-store";
3
+ import type { Page, PagedData, PagerRefreshOptions } from "./types";
4
+ import { Debouncer } from "@asaidimu/utils-sync";
5
+
6
+ export interface PageOptions<T extends Record<string, any>> {
7
+ page?: number;
8
+ size?: number;
9
+ sort?: SortConfiguration<T> | SortConfiguration<T>[];
10
+ filter?: QueryFilter<T>;
11
+ }
12
+
13
+ const sentinel = {
14
+ data: [],
15
+ loading: true,
16
+ page: {
17
+ number: 1,
18
+ size: 20,
19
+ count: 0,
20
+ total: 0,
21
+ pages: 1,
22
+ },
23
+ };
24
+ export function createPagedController<T extends Record<string, any>>(
25
+ collectionName: string,
26
+ store: DataStore<any>,
27
+ options: PageOptions<T>,
28
+ find: (query: QueryDSL<T>) => Promise<Page<T>>,
29
+ ): PagedData<T> {
30
+ let currentPage = options.page ?? 1;
31
+ let currentSize = options.size ?? 20;
32
+ let currentSort = options.sort;
33
+ let currentFilter = options.filter;
34
+ const debounce = new Debouncer({ delay: 50 });
35
+
36
+ const CACHE_KEY = `${collectionName}_pager_state_`;
37
+ store.set({
38
+ [CACHE_KEY]: {
39
+ data: [],
40
+ loading: false,
41
+ error: DELETE_SYMBOL,
42
+ page: { number: 1, size: 20, count: 0, total: 0, pages: 1 },
43
+ },
44
+ });
45
+
46
+ const buildQuery = (opts?: PagerRefreshOptions): QueryDSL<T> => {
47
+ const page = opts?.page ?? currentPage;
48
+ const size = opts?.size ?? currentSize;
49
+ const query: QueryDSL<T> = {
50
+ pagination: {
51
+ type: "offset",
52
+ offset: (page - 1) * size,
53
+ limit: size,
54
+ },
55
+ };
56
+ const sort = opts?.sort ?? currentSort;
57
+ if (sort) {
58
+ query.sort = Array.isArray(sort) ? sort : [sort];
59
+ }
60
+ const filter = opts?.filter ?? currentFilter;
61
+ if (filter) {
62
+ query.filters = filter;
63
+ }
64
+ return query;
65
+ };
66
+
67
+ const load = async (opts?: PagerRefreshOptions) => {
68
+ await store.set({
69
+ [CACHE_KEY]: {
70
+ loading: true,
71
+ error: DELETE_SYMBOL,
72
+ },
73
+ });
74
+
75
+ return await new Promise((resolve) => {
76
+ resolve(
77
+ debounce.do(async () => {
78
+ requestIdleCallback(() => {
79
+ requestIdleCallback(async () => {
80
+ try {
81
+ const result = await find(buildQuery(opts));
82
+ await store.set({ [CACHE_KEY]: result });
83
+ } catch (error) {
84
+ await store.set({
85
+ [CACHE_KEY]: {
86
+ ...sentinel,
87
+ error,
88
+ },
89
+ });
90
+ } finally {
91
+ await store.set({
92
+ [CACHE_KEY]: {
93
+ loading: false,
94
+ },
95
+ });
96
+ }
97
+ });
98
+ });
99
+ }),
100
+ );
101
+ });
102
+ };
103
+
104
+ const selector = store.select((s: any) => s[CACHE_KEY]);
105
+ // TODO: Investigate bug. If by any chance a selectors subscription reaches
106
+ // zero, it is auto discarded. This might be a problem if we are holding it
107
+ // fix this
108
+ const unsub = selector.subscribe(() => {});
109
+
110
+ const controller: PagedData<T> = {
111
+ page: () => selector.get() ?? sentinel,
112
+
113
+ navigate: async (page: number) => {
114
+ if (page < 1) throw new Error("Page number must be >= 1");
115
+ currentPage = page;
116
+ await load({
117
+ page,
118
+ });
119
+ },
120
+
121
+ resize: async (size: number, page: number) => {
122
+ if (size < 1) throw new Error("Page size must be >= 1");
123
+ currentSize = size;
124
+ currentPage = page;
125
+ await load();
126
+ },
127
+
128
+ sort: async (sort) => {
129
+ currentSort = Array.isArray(sort) ? sort : [sort];
130
+ currentPage = 1;
131
+ await load();
132
+ },
133
+
134
+ filter: async (filter) => {
135
+ currentFilter = filter;
136
+ currentPage = 1;
137
+ await load();
138
+ },
139
+
140
+ refresh: async (opts) => {
141
+ await Promise.all([
142
+ new Promise((resolve) => setTimeout(resolve, opts?.delay || 0)),
143
+ load(opts),
144
+ ]);
145
+ },
146
+
147
+ subscribe: (listener) => {
148
+ return selector.subscribe(listener);
149
+ },
150
+
151
+ invalidate: async () => {
152
+ await store.set({ [CACHE_KEY]: DELETE_SYMBOL });
153
+ unsub();
154
+ },
155
+
156
+ id: () => CACHE_KEY,
157
+ };
158
+ return controller;
159
+ }