@startsimpli/api 0.1.0 → 0.2.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.
- package/dist/index.d.mts +2235 -0
- package/dist/index.d.ts +2235 -0
- package/dist/index.js +2092 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2010 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -14
- package/src/__tests__/drf-transforms.test.ts +99 -0
- package/src/__tests__/entity-query-builder.test.ts +197 -0
- package/src/__tests__/funnels-api.test.ts +237 -0
- package/src/__tests__/jwt-refresh.test.ts +11 -14
- package/src/__tests__/url-builder.test.ts +27 -1
- package/src/__tests__/validate-response.test.ts +68 -0
- package/src/constants/endpoints.ts +13 -0
- package/src/constants/options.ts +83 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-server-detail.ts +71 -0
- package/src/hooks/use-server-list.ts +169 -0
- package/src/index.ts +36 -2
- package/src/lib/api-client.ts +10 -19
- package/src/lib/cache-manager.ts +103 -0
- package/src/lib/cache-store.ts +113 -0
- package/src/lib/error-handler.ts +1 -1
- package/src/lib/fetch-wrapper.ts +38 -26
- package/src/lib/funnels-api.ts +221 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/with-rate-limit.ts +54 -0
- package/src/utils/drf-transforms.ts +64 -0
- package/src/utils/entity-query-builder.ts +174 -0
- package/src/utils/index.ts +11 -1
- package/src/utils/url-builder.ts +27 -0
- package/src/utils/validate-response.ts +39 -0
- package/src/lib/messages-api.ts.backup +0 -273
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ServerDetailOptions {
|
|
6
|
+
/** Custom fetch function. Use authFetch from @startsimpli/auth for authenticated calls. */
|
|
7
|
+
fetcher?: typeof fetch
|
|
8
|
+
/** Pass null to skip fetching (e.g., when ID is not yet known) */
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ServerDetailResult<T> {
|
|
13
|
+
data: T | null
|
|
14
|
+
loading: boolean
|
|
15
|
+
error: string | null
|
|
16
|
+
refresh: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook for fetching a single resource.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { useServerDetail } from '@startsimpli/api'
|
|
24
|
+
* import { authFetch } from '@startsimpli/auth'
|
|
25
|
+
*
|
|
26
|
+
* const { data, loading, error } = useServerDetail<User>(
|
|
27
|
+
* '/api/v1/users/',
|
|
28
|
+
* userId,
|
|
29
|
+
* { fetcher: authFetch }
|
|
30
|
+
* )
|
|
31
|
+
*/
|
|
32
|
+
export function useServerDetail<T>(
|
|
33
|
+
endpoint: string,
|
|
34
|
+
id?: string | number | null,
|
|
35
|
+
options: ServerDetailOptions = {}
|
|
36
|
+
): ServerDetailResult<T> {
|
|
37
|
+
const { fetcher = fetch, disabled = false } = options
|
|
38
|
+
|
|
39
|
+
const [data, setData] = useState<T | null>(null)
|
|
40
|
+
const [loading, setLoading] = useState(!disabled && id !== null && id !== undefined)
|
|
41
|
+
const [error, setError] = useState<string | null>(null)
|
|
42
|
+
|
|
43
|
+
const fetchData = useCallback(async () => {
|
|
44
|
+
if (disabled || id === null) return
|
|
45
|
+
|
|
46
|
+
const url =
|
|
47
|
+
id !== undefined
|
|
48
|
+
? `${endpoint}${endpoint.endsWith('/') ? '' : '/'}${id}/`
|
|
49
|
+
: endpoint
|
|
50
|
+
|
|
51
|
+
setLoading(true)
|
|
52
|
+
setError(null)
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetcher(url)
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
|
|
57
|
+
}
|
|
58
|
+
setData(await response.json())
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err instanceof Error ? err.message : 'Failed to load data')
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false)
|
|
63
|
+
}
|
|
64
|
+
}, [endpoint, id, disabled, fetcher])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
fetchData()
|
|
68
|
+
}, [fetchData])
|
|
69
|
+
|
|
70
|
+
return { data, loading, error, refresh: fetchData }
|
|
71
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ServerListOptions {
|
|
6
|
+
pageSize?: number
|
|
7
|
+
initialPage?: number
|
|
8
|
+
initialSearch?: string
|
|
9
|
+
initialSortField?: string
|
|
10
|
+
initialSortDir?: 'asc' | 'desc'
|
|
11
|
+
/** Extra static query params to append on every request */
|
|
12
|
+
params?: Record<string, string | number | boolean>
|
|
13
|
+
/** Disable auto-fetch on mount */
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
/** Custom fetch function (defaults to global fetch). Use authFetch from @startsimpli/auth for authenticated calls. */
|
|
16
|
+
fetcher?: typeof fetch
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ServerListResult<T> {
|
|
20
|
+
data: T[]
|
|
21
|
+
total: number
|
|
22
|
+
loading: boolean
|
|
23
|
+
error: string | null
|
|
24
|
+
page: number
|
|
25
|
+
pageSize: number
|
|
26
|
+
search: string
|
|
27
|
+
sortField: string
|
|
28
|
+
sortDir: 'asc' | 'desc'
|
|
29
|
+
setPage: (page: number) => void
|
|
30
|
+
setPageSize: (size: number) => void
|
|
31
|
+
setSearch: (search: string) => void
|
|
32
|
+
setSort: (field: string, dir?: 'asc' | 'desc') => void
|
|
33
|
+
refresh: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hook for server-side paginated list fetching.
|
|
38
|
+
* Sends ?page=N&pageSize=Y&sortField=Z&sortDirection=asc|desc&search=Q
|
|
39
|
+
* to the provided endpoint.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* import { useServerList } from '@startsimpli/api'
|
|
43
|
+
* import { authFetch } from '@startsimpli/auth'
|
|
44
|
+
*
|
|
45
|
+
* const { data, total, loading, page, setPage, setSearch } = useServerList<Email>(
|
|
46
|
+
* '/api/v1/emails/',
|
|
47
|
+
* { fetcher: authFetch, pageSize: 25 }
|
|
48
|
+
* )
|
|
49
|
+
*/
|
|
50
|
+
export function useServerList<T>(
|
|
51
|
+
endpoint: string,
|
|
52
|
+
options: ServerListOptions = {}
|
|
53
|
+
): ServerListResult<T> {
|
|
54
|
+
const {
|
|
55
|
+
pageSize: initialPageSize = 25,
|
|
56
|
+
initialPage = 1,
|
|
57
|
+
initialSearch = '',
|
|
58
|
+
initialSortField = '',
|
|
59
|
+
initialSortDir = 'asc',
|
|
60
|
+
params: extraParams = {},
|
|
61
|
+
disabled = false,
|
|
62
|
+
fetcher = fetch,
|
|
63
|
+
} = options
|
|
64
|
+
|
|
65
|
+
const [page, setPage] = useState(initialPage)
|
|
66
|
+
const [pageSize, setPageSize] = useState(initialPageSize)
|
|
67
|
+
const [search, setSearchState] = useState(initialSearch)
|
|
68
|
+
const [sortField, setSortField] = useState(initialSortField)
|
|
69
|
+
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(initialSortDir)
|
|
70
|
+
const [data, setData] = useState<T[]>([])
|
|
71
|
+
const [total, setTotal] = useState(0)
|
|
72
|
+
const [loading, setLoading] = useState(!disabled)
|
|
73
|
+
const [error, setError] = useState<string | null>(null)
|
|
74
|
+
const refreshCountRef = useRef(0)
|
|
75
|
+
|
|
76
|
+
const fetchData = useCallback(async () => {
|
|
77
|
+
if (disabled) return
|
|
78
|
+
|
|
79
|
+
setLoading(true)
|
|
80
|
+
setError(null)
|
|
81
|
+
|
|
82
|
+
const searchParams = new URLSearchParams()
|
|
83
|
+
searchParams.set('page', String(page))
|
|
84
|
+
searchParams.set('pageSize', String(pageSize))
|
|
85
|
+
if (sortField) {
|
|
86
|
+
searchParams.set('sortField', sortField)
|
|
87
|
+
searchParams.set('sortDirection', sortDir)
|
|
88
|
+
}
|
|
89
|
+
if (search) searchParams.set('search', search)
|
|
90
|
+
for (const [k, v] of Object.entries(extraParams)) {
|
|
91
|
+
searchParams.set(k, String(v))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sep = endpoint.includes('?') ? '&' : '?'
|
|
95
|
+
const url = `${endpoint}${sep}${searchParams.toString()}`
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetcher(url)
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
|
|
101
|
+
}
|
|
102
|
+
const json = await response.json()
|
|
103
|
+
// Support { results: T[], count: N } (DRF default), { data: T[], total: N }, or plain array
|
|
104
|
+
if (Array.isArray(json)) {
|
|
105
|
+
setData(json)
|
|
106
|
+
setTotal(json.length)
|
|
107
|
+
} else if (json.results !== undefined) {
|
|
108
|
+
setData(json.results)
|
|
109
|
+
setTotal(json.count ?? json.results.length)
|
|
110
|
+
} else if (json.data !== undefined) {
|
|
111
|
+
setData(json.data)
|
|
112
|
+
setTotal(json.total ?? json.data.length)
|
|
113
|
+
} else {
|
|
114
|
+
setData([])
|
|
115
|
+
setTotal(0)
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setError(err instanceof Error ? err.message : 'Failed to load data')
|
|
119
|
+
setData([])
|
|
120
|
+
} finally {
|
|
121
|
+
setLoading(false)
|
|
122
|
+
}
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
}, [endpoint, page, pageSize, search, sortField, sortDir, disabled, fetcher, refreshCountRef.current])
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
fetchData()
|
|
128
|
+
}, [fetchData])
|
|
129
|
+
|
|
130
|
+
const setSearch = useCallback((value: string) => {
|
|
131
|
+
setPage(1)
|
|
132
|
+
setSearchState(value)
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
const setSort = useCallback(
|
|
136
|
+
(field: string, dir?: 'asc' | 'desc') => {
|
|
137
|
+
if (field === sortField && !dir) {
|
|
138
|
+
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
|
|
139
|
+
} else {
|
|
140
|
+
setSortField(field)
|
|
141
|
+
setSortDir(dir ?? 'asc')
|
|
142
|
+
}
|
|
143
|
+
setPage(1)
|
|
144
|
+
},
|
|
145
|
+
[sortField]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const refresh = useCallback(() => {
|
|
149
|
+
refreshCountRef.current += 1
|
|
150
|
+
fetchData()
|
|
151
|
+
}, [fetchData])
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
data,
|
|
155
|
+
total,
|
|
156
|
+
loading,
|
|
157
|
+
error,
|
|
158
|
+
page,
|
|
159
|
+
pageSize,
|
|
160
|
+
search,
|
|
161
|
+
sortField,
|
|
162
|
+
sortDir,
|
|
163
|
+
setPage,
|
|
164
|
+
setPageSize,
|
|
165
|
+
setSearch,
|
|
166
|
+
setSort,
|
|
167
|
+
refresh,
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { EntitiesApi } from './lib/entities-api';
|
|
|
16
16
|
export { WorkflowsApi } from './lib/workflows-api';
|
|
17
17
|
export { MessagesApi } from './lib/messages-api';
|
|
18
18
|
export type { Message, MessageStatus as MessageApiStatus, MessageRecipient, MessagingChannel as MessagingChannelType } from './lib/messages-api';
|
|
19
|
+
export { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from './lib/funnels-api';
|
|
20
|
+
export type { FunnelPreviewResult, FunnelRunFilters, FunnelTemplate } from './lib/funnels-api';
|
|
19
21
|
|
|
20
22
|
// Fetch wrapper
|
|
21
23
|
export { FetchWrapper } from './lib/fetch-wrapper';
|
|
@@ -74,11 +76,40 @@ export * from './types';
|
|
|
74
76
|
export { createRateLimiter, getClientIP } from './lib/rate-limit';
|
|
75
77
|
export type { RateLimitOptions, RateLimitResult } from './lib/rate-limit';
|
|
76
78
|
|
|
79
|
+
// In-memory cache (server-side only)
|
|
80
|
+
export { CacheStore } from './lib/cache-store';
|
|
81
|
+
export type { CacheStoreConfig } from './lib/cache-store';
|
|
82
|
+
export { CacheManager, getCacheManager, resetCacheManager } from './lib/cache-manager';
|
|
83
|
+
export type { CacheManagerConfig } from './lib/cache-manager';
|
|
84
|
+
|
|
77
85
|
// Utils
|
|
78
86
|
export * from './utils';
|
|
79
87
|
|
|
80
88
|
// Constants
|
|
81
89
|
export { ENDPOINTS } from './constants/endpoints';
|
|
90
|
+
export {
|
|
91
|
+
COMPANY_SIZE_OPTIONS,
|
|
92
|
+
LIFECYCLE_STAGE_OPTIONS,
|
|
93
|
+
REVENUE_RANGE_OPTIONS,
|
|
94
|
+
ACTIVITY_TYPE_OPTIONS,
|
|
95
|
+
ACTIVITY_OUTCOME_OPTIONS,
|
|
96
|
+
LOSS_REASON_OPTIONS,
|
|
97
|
+
DEAL_STAGE_OPTIONS,
|
|
98
|
+
} from './constants/options';
|
|
99
|
+
export type {
|
|
100
|
+
SelectOption,
|
|
101
|
+
CompanySize,
|
|
102
|
+
LifecycleStage,
|
|
103
|
+
RevenueRange,
|
|
104
|
+
ActivityType,
|
|
105
|
+
ActivityOutcome,
|
|
106
|
+
LossReason,
|
|
107
|
+
DealStage,
|
|
108
|
+
} from './constants/options';
|
|
109
|
+
|
|
110
|
+
// React hooks for server-side data fetching
|
|
111
|
+
export { useServerList, useServerDetail } from './hooks';
|
|
112
|
+
export type { ServerListOptions, ServerListResult, ServerDetailResult } from './hooks';
|
|
82
113
|
|
|
83
114
|
// Environment variable utilities
|
|
84
115
|
export { getRequiredEnv, getOptionalEnv, validateEnvVars } from './lib/env';
|
|
@@ -89,13 +120,15 @@ import { OrganizationsApi } from './lib/organizations-api';
|
|
|
89
120
|
import { EntitiesApi } from './lib/entities-api';
|
|
90
121
|
import { WorkflowsApi } from './lib/workflows-api';
|
|
91
122
|
import { MessagesApi } from './lib/messages-api';
|
|
123
|
+
import { FunnelsApi } from './lib/funnels-api';
|
|
92
124
|
|
|
93
125
|
import type { ApiClientConfig } from './lib/api-client';
|
|
94
126
|
|
|
95
127
|
/**
|
|
96
|
-
* Create a complete API client with all endpoints
|
|
128
|
+
* Create a complete API client with all endpoints.
|
|
129
|
+
* All config fields are optional — baseUrl defaults to '' (relative URLs via Next.js proxy).
|
|
97
130
|
*/
|
|
98
|
-
export function createStartSimpliApi(config: ApiClientConfig) {
|
|
131
|
+
export function createStartSimpliApi(config: ApiClientConfig = {}) {
|
|
99
132
|
const client = createApiClient(config);
|
|
100
133
|
|
|
101
134
|
return {
|
|
@@ -105,5 +138,6 @@ export function createStartSimpliApi(config: ApiClientConfig) {
|
|
|
105
138
|
entities: new EntitiesApi(client),
|
|
106
139
|
workflows: new WorkflowsApi(client),
|
|
107
140
|
messages: new MessagesApi(client),
|
|
141
|
+
funnels: new FunnelsApi(client),
|
|
108
142
|
};
|
|
109
143
|
}
|
package/src/lib/api-client.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Main API client for Django REST API
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { FetchWrapper
|
|
5
|
+
import { FetchWrapper } from './fetch-wrapper';
|
|
6
|
+
import type { FetchOptions } from '../types';
|
|
6
7
|
|
|
7
8
|
export interface ApiClientConfig {
|
|
8
|
-
baseUrl
|
|
9
|
+
baseUrl?: string;
|
|
9
10
|
getToken?: () => Promise<string | null> | string | null;
|
|
10
11
|
onUnauthorized?: () => void;
|
|
11
12
|
onTokenRefresh?: () => Promise<string | null>;
|
|
@@ -16,7 +17,7 @@ export class ApiClient {
|
|
|
16
17
|
public readonly baseUrl: string;
|
|
17
18
|
|
|
18
19
|
constructor(config: ApiClientConfig) {
|
|
19
|
-
this.baseUrl = config.baseUrl;
|
|
20
|
+
this.baseUrl = config.baseUrl ?? '';
|
|
20
21
|
|
|
21
22
|
this.fetcher = new FetchWrapper({
|
|
22
23
|
baseUrl: config.baseUrl,
|
|
@@ -41,42 +42,32 @@ export class ApiClient {
|
|
|
41
42
|
* Update auth token getter
|
|
42
43
|
*/
|
|
43
44
|
setTokenGetter(getToken: () => Promise<string | null> | string | null): void {
|
|
44
|
-
this.fetcher =
|
|
45
|
-
baseUrl: this.baseUrl,
|
|
46
|
-
getToken,
|
|
47
|
-
onUnauthorized: this.fetcher['config'].onUnauthorized,
|
|
48
|
-
defaultHeaders: this.fetcher['config'].defaultHeaders,
|
|
49
|
-
});
|
|
45
|
+
this.fetcher = this.fetcher.reconfigure({ getToken });
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
/**
|
|
53
49
|
* Update unauthorized handler
|
|
54
50
|
*/
|
|
55
51
|
setUnauthorizedHandler(onUnauthorized: () => void): void {
|
|
56
|
-
this.fetcher =
|
|
57
|
-
baseUrl: this.baseUrl,
|
|
58
|
-
getToken: this.fetcher['config'].getToken,
|
|
59
|
-
onUnauthorized,
|
|
60
|
-
defaultHeaders: this.fetcher['config'].defaultHeaders,
|
|
61
|
-
});
|
|
52
|
+
this.fetcher = this.fetcher.reconfigure({ onUnauthorized });
|
|
62
53
|
}
|
|
63
54
|
|
|
64
55
|
/**
|
|
65
56
|
* Convenience HTTP methods that delegate to FetchWrapper
|
|
66
57
|
*/
|
|
67
|
-
async get<T>(endpoint: string, options?:
|
|
58
|
+
async get<T>(endpoint: string, options?: FetchOptions): Promise<T> {
|
|
68
59
|
return this.fetcher.get<T>(endpoint, options);
|
|
69
60
|
}
|
|
70
61
|
|
|
71
|
-
async post<T, D = unknown>(endpoint: string, data?: D, options?:
|
|
62
|
+
async post<T, D = unknown>(endpoint: string, data?: D, options?: FetchOptions): Promise<T> {
|
|
72
63
|
return this.fetcher.post<T, D>(endpoint, data, options);
|
|
73
64
|
}
|
|
74
65
|
|
|
75
|
-
async patch<T, D = unknown>(endpoint: string, data?: D, options?:
|
|
66
|
+
async patch<T, D = unknown>(endpoint: string, data?: D, options?: FetchOptions): Promise<T> {
|
|
76
67
|
return this.fetcher.patch<T, D>(endpoint, data, options);
|
|
77
68
|
}
|
|
78
69
|
|
|
79
|
-
async delete<T>(endpoint: string, options?:
|
|
70
|
+
async delete<T>(endpoint: string, options?: FetchOptions): Promise<T> {
|
|
80
71
|
return this.fetcher.delete<T>(endpoint, options);
|
|
81
72
|
}
|
|
82
73
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache manager built on top of CacheStore.
|
|
3
|
+
*
|
|
4
|
+
* Provides a cache-aside pattern (getOrSet), pattern-based invalidation,
|
|
5
|
+
* and separate entity/query stores — ready for use in Next.js API routes
|
|
6
|
+
* and server-side data fetching.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CacheStore } from './cache-store'
|
|
10
|
+
import type { CacheStoreConfig } from './cache-store'
|
|
11
|
+
|
|
12
|
+
export interface CacheManagerConfig {
|
|
13
|
+
entity?: Partial<CacheStoreConfig>
|
|
14
|
+
query?: Partial<CacheStoreConfig>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONFIG: CacheManagerConfig = {
|
|
18
|
+
entity: { maxSize: 500, defaultTTL: 5 * 60 * 1000 },
|
|
19
|
+
query: { maxSize: 200, defaultTTL: 60 * 1000 },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class CacheManager {
|
|
23
|
+
readonly entity: CacheStore
|
|
24
|
+
readonly query: CacheStore
|
|
25
|
+
private enabled: boolean
|
|
26
|
+
|
|
27
|
+
constructor(enabled = true, config: CacheManagerConfig = {}) {
|
|
28
|
+
this.entity = new CacheStore({ ...DEFAULT_CONFIG.entity, ...config.entity })
|
|
29
|
+
this.query = new CacheStore({ ...DEFAULT_CONFIG.query, ...config.query })
|
|
30
|
+
this.enabled = enabled
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cache-aside: return the cached value if present, otherwise call fetchFn,
|
|
35
|
+
* store the result, and return it.
|
|
36
|
+
*/
|
|
37
|
+
async getOrSet<T>(key: string, ttlMs: number, fetchFn: () => Promise<T>): Promise<T> {
|
|
38
|
+
if (this.enabled) {
|
|
39
|
+
const cached = this.query.get(key) as T | undefined
|
|
40
|
+
if (cached !== undefined) return cached
|
|
41
|
+
}
|
|
42
|
+
const value = await fetchFn()
|
|
43
|
+
if (this.enabled) this.query.set(key, value, ttlMs)
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Invalidate a specific key in the query cache */
|
|
48
|
+
invalidateKey(key: string): void {
|
|
49
|
+
this.query.delete(key)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Invalidate all query cache keys matching a string prefix or RegExp */
|
|
53
|
+
invalidatePattern(pattern: string | RegExp): void {
|
|
54
|
+
this.query.deletePattern(pattern)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Invalidate all entity and query cache entries for the given prefix */
|
|
58
|
+
invalidateAll(prefix: string): void {
|
|
59
|
+
this.entity.deletePattern(prefix)
|
|
60
|
+
this.query.deletePattern(prefix)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setEnabled(enabled: boolean): void {
|
|
64
|
+
this.enabled = enabled
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isEnabled(): boolean {
|
|
68
|
+
return this.enabled
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove expired entries from both stores */
|
|
72
|
+
maintenance(): { entityRemoved: number; queryRemoved: number } {
|
|
73
|
+
return {
|
|
74
|
+
entityRemoved: this.entity.cleanup(),
|
|
75
|
+
queryRemoved: this.query.cleanup(),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
clear(): void {
|
|
80
|
+
this.entity.clear()
|
|
81
|
+
this.query.clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getStats(): {
|
|
85
|
+
entity: ReturnType<CacheStore['getStats']>
|
|
86
|
+
query: ReturnType<CacheStore['getStats']>
|
|
87
|
+
} {
|
|
88
|
+
return { entity: this.entity.getStats(), query: this.query.getStats() }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Singleton factory
|
|
93
|
+
let _cacheManager: CacheManager | null = null
|
|
94
|
+
|
|
95
|
+
export function getCacheManager(config?: CacheManagerConfig): CacheManager {
|
|
96
|
+
if (!_cacheManager) _cacheManager = new CacheManager(true, config)
|
|
97
|
+
return _cacheManager
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resetCacheManager(): void {
|
|
101
|
+
_cacheManager?.clear()
|
|
102
|
+
_cacheManager = null
|
|
103
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory LRU cache store with TTL expiry.
|
|
3
|
+
*
|
|
4
|
+
* A minimal, zero-dependency cache for server-side caching in Next.js apps.
|
|
5
|
+
* State is in-memory and resets on server restart — not suitable for
|
|
6
|
+
* multi-instance production deployments without a shared backend (e.g. Redis).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface CacheEntry<T> {
|
|
10
|
+
value: T
|
|
11
|
+
expiresAt: number
|
|
12
|
+
accessOrder: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CacheStoreConfig {
|
|
16
|
+
/** Maximum number of entries before LRU eviction (default: 1000) */
|
|
17
|
+
maxSize: number
|
|
18
|
+
/** Default TTL in milliseconds (default: 60 seconds) */
|
|
19
|
+
defaultTTL: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONFIG: CacheStoreConfig = {
|
|
23
|
+
maxSize: 1000,
|
|
24
|
+
defaultTTL: 60 * 1000,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CacheStore<T = unknown> {
|
|
28
|
+
private cache = new Map<string, CacheEntry<T>>()
|
|
29
|
+
private config: CacheStoreConfig
|
|
30
|
+
private hits = 0
|
|
31
|
+
private misses = 0
|
|
32
|
+
private accessCounter = 0
|
|
33
|
+
|
|
34
|
+
constructor(config: Partial<CacheStoreConfig> = {}) {
|
|
35
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(key: string): T | undefined {
|
|
39
|
+
const entry = this.cache.get(key)
|
|
40
|
+
if (!entry) { this.misses++; return undefined }
|
|
41
|
+
if (Date.now() > entry.expiresAt) { this.cache.delete(key); this.misses++; return undefined }
|
|
42
|
+
entry.accessOrder = ++this.accessCounter
|
|
43
|
+
this.hits++
|
|
44
|
+
return entry.value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
set(key: string, value: T, ttl?: number): void {
|
|
48
|
+
if (this.cache.size >= this.config.maxSize) this.evictLRU()
|
|
49
|
+
this.cache.set(key, {
|
|
50
|
+
value,
|
|
51
|
+
expiresAt: Date.now() + (ttl ?? this.config.defaultTTL),
|
|
52
|
+
accessOrder: ++this.accessCounter,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
has(key: string): boolean {
|
|
57
|
+
const entry = this.cache.get(key)
|
|
58
|
+
if (!entry) return false
|
|
59
|
+
if (Date.now() > entry.expiresAt) { this.cache.delete(key); return false }
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
delete(key: string): boolean {
|
|
64
|
+
return this.cache.delete(key)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Delete all keys matching a string prefix or RegExp pattern */
|
|
68
|
+
deletePattern(pattern: string | RegExp): number {
|
|
69
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern
|
|
70
|
+
let deleted = 0
|
|
71
|
+
for (const key of this.cache.keys()) {
|
|
72
|
+
if (regex.test(key)) { this.cache.delete(key); deleted++ }
|
|
73
|
+
}
|
|
74
|
+
return deleted
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clear(): void {
|
|
78
|
+
this.cache.clear()
|
|
79
|
+
this.hits = 0
|
|
80
|
+
this.misses = 0
|
|
81
|
+
this.accessCounter = 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remove expired entries and return count removed */
|
|
85
|
+
cleanup(): number {
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
let removed = 0
|
|
88
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
89
|
+
if (now > entry.expiresAt) { this.cache.delete(key); removed++ }
|
|
90
|
+
}
|
|
91
|
+
return removed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getStats(): { size: number; maxSize: number; hits: number; misses: number; hitRate: number } {
|
|
95
|
+
const total = this.hits + this.misses
|
|
96
|
+
return {
|
|
97
|
+
size: this.cache.size,
|
|
98
|
+
maxSize: this.config.maxSize,
|
|
99
|
+
hits: this.hits,
|
|
100
|
+
misses: this.misses,
|
|
101
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private evictLRU(): void {
|
|
106
|
+
let oldestKey: string | null = null
|
|
107
|
+
let oldestOrder = Infinity
|
|
108
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
109
|
+
if (entry.accessOrder < oldestOrder) { oldestOrder = entry.accessOrder; oldestKey = key }
|
|
110
|
+
}
|
|
111
|
+
if (oldestKey) this.cache.delete(oldestKey)
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/lib/error-handler.ts
CHANGED
|
@@ -34,7 +34,7 @@ export class ApiException extends Error {
|
|
|
34
34
|
* Parse Django REST Framework error response
|
|
35
35
|
*/
|
|
36
36
|
export async function parseErrorResponse(response: Response): Promise<DRFApiError> {
|
|
37
|
-
const contentType = response.headers
|
|
37
|
+
const contentType = response.headers?.get('content-type');
|
|
38
38
|
|
|
39
39
|
// Try to parse JSON error
|
|
40
40
|
if (contentType?.includes('application/json')) {
|