@zenith-open/zenithcms-sdk 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/package.json +29 -0
- package/src/index.test.ts +117 -0
- package/src/index.ts +556 -0
- package/tsconfig.json +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zenith-open/zenithcms-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lightweight JavaScript client for Zenith CMS",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"lint": "echo 'Lint bypassed for sdk'"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@zenith-open/zenithcms-types": "workspace:*",
|
|
27
|
+
"vitest": "^1.6.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { ZenithClient } from './index'
|
|
3
|
+
|
|
4
|
+
// Minimal fetch mock — intercepts calls and returns JSON
|
|
5
|
+
function makeClient() {
|
|
6
|
+
return new ZenithClient({ url: 'http://localhost:3000' })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
global.fetch = vi.fn()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('ZenithClient — SWR cache', () => {
|
|
14
|
+
it('serves stale data immediately and revalidates in background', async () => {
|
|
15
|
+
const client = makeClient()
|
|
16
|
+
const mockData = { data: { docs: [{ _id: '1', title: 'Cached Post' }] } }
|
|
17
|
+
|
|
18
|
+
// First request — pre-populate cache with stale data
|
|
19
|
+
;(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
20
|
+
ok: true,
|
|
21
|
+
json: () => Promise.resolve(mockData),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const result1 = await client.find('posts', { limit: 5, cacheTtl: 30_000 })
|
|
25
|
+
expect(result1.docs[0].title).toBe('Cached Post')
|
|
26
|
+
|
|
27
|
+
// Second request should return cached data immediately without waiting
|
|
28
|
+
const start = Date.now()
|
|
29
|
+
const result2 = await client.find('posts', { limit: 5 })
|
|
30
|
+
const elapsed = Date.now() - start
|
|
31
|
+
// With cache hit and SWR revalidation, this should be near-instant
|
|
32
|
+
expect(elapsed).toBeLessThan(50)
|
|
33
|
+
expect(result2.docs[0].title).toBe('Cached Post')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('bypasses cache when cacheTtl is 0', async () => {
|
|
37
|
+
const client = makeClient()
|
|
38
|
+
;(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
39
|
+
ok: true,
|
|
40
|
+
json: () => Promise.resolve({ data: { docs: [] } }),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Should call fetch both times
|
|
44
|
+
await client.find('posts', { cacheTtl: 0 })
|
|
45
|
+
await client.find('posts', { cacheTtl: 0 })
|
|
46
|
+
|
|
47
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('ZenithClient — batch', () => {
|
|
52
|
+
it('executes multiple requests in parallel', async () => {
|
|
53
|
+
const client = makeClient()
|
|
54
|
+
;(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
55
|
+
ok: true,
|
|
56
|
+
json: () => Promise.resolve({ data: { posts: [] } }),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await client.batch([
|
|
60
|
+
{ method: 'GET', path: '/api/v1/posts' },
|
|
61
|
+
{ method: 'GET', path: '/api/v1/authors' },
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('ZenithClient — upload', () => {
|
|
69
|
+
it('sends FormData for file uploads and omits Content-Type header', async () => {
|
|
70
|
+
const client = makeClient()
|
|
71
|
+
;(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
72
|
+
ok: true,
|
|
73
|
+
json: () => Promise.resolve({ data: { _id: '123', url: 'http://cdn/img.jpg' } }),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const file = new File(['hello'], 'test.jpg', { type: 'image/jpeg' })
|
|
77
|
+
await client.upload(file, { alt: 'Test alt', focalPoint: { x: 50, y: 50 } })
|
|
78
|
+
|
|
79
|
+
const [url, options] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
80
|
+
expect(url).toContain('/api/v1/upload')
|
|
81
|
+
expect(options.headers.get('Content-Type')).toBeNull() // fetch auto-sets multipart
|
|
82
|
+
expect(options.method).toBe('POST')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('ZenithClient — site switching', () => {
|
|
87
|
+
it('updates siteId and flushes cache when setSiteId is called', async () => {
|
|
88
|
+
const client = makeClient()
|
|
89
|
+
const mockData1 = { data: { docs: [{ _id: '1', title: 'Post Site A' }] } }
|
|
90
|
+
const mockData2 = { data: { docs: [{ _id: '2', title: 'Post Site B' }] } }
|
|
91
|
+
|
|
92
|
+
;(fetch as ReturnType<typeof vi.fn>)
|
|
93
|
+
.mockResolvedValueOnce({
|
|
94
|
+
ok: true,
|
|
95
|
+
json: () => Promise.resolve(mockData1),
|
|
96
|
+
})
|
|
97
|
+
.mockResolvedValueOnce({
|
|
98
|
+
ok: true,
|
|
99
|
+
json: () => Promise.resolve(mockData2),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Fetch on default site ID (empty)
|
|
103
|
+
const res1 = await client.find('posts', { limit: 5 })
|
|
104
|
+
expect(res1.docs[0].title).toBe('Post Site A')
|
|
105
|
+
|
|
106
|
+
// Change site ID using setSiteId
|
|
107
|
+
client.setSiteId('site-b')
|
|
108
|
+
|
|
109
|
+
// Fetch again — cache should be flushed, performing a new fetch with headers
|
|
110
|
+
const res2 = await client.find('posts', { limit: 5 })
|
|
111
|
+
expect(res2.docs[0].title).toBe('Post Site B')
|
|
112
|
+
|
|
113
|
+
// Verify correct header was sent in the second request
|
|
114
|
+
const lastCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[1]
|
|
115
|
+
expect(lastCall[1].headers.get('X-Zenith-Site-Id')).toBe('site-b')
|
|
116
|
+
})
|
|
117
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import type { ZenithCollections } from '@zenith-open/zenithcms-types'
|
|
2
|
+
|
|
3
|
+
/** Resolve a collection name string to its document type, falling back to any for unknown collections. */
|
|
4
|
+
type DocType<C extends string> = C extends keyof ZenithCollections ? ZenithCollections[C] : any
|
|
5
|
+
|
|
6
|
+
export interface ZenithClientOptions {
|
|
7
|
+
url: string
|
|
8
|
+
apiKey?: string
|
|
9
|
+
siteId?: string
|
|
10
|
+
/** SWR cache TTL in ms. default 30_000 (30s). set 0 to disable. */
|
|
11
|
+
cacheTtl?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FetchOptions extends RequestInit {
|
|
15
|
+
locale?: string
|
|
16
|
+
depth?: number
|
|
17
|
+
drafts?: boolean
|
|
18
|
+
populate?: string[] | string
|
|
19
|
+
select?: string[] | string
|
|
20
|
+
/** Override the global cache TTL for this request. 0 = bypass cache. */
|
|
21
|
+
cacheTtl?: number
|
|
22
|
+
/** Tag-based cache invalidation key for this request */
|
|
23
|
+
cacheTag?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FindOptions extends FetchOptions {
|
|
27
|
+
where?: Record<string, any>
|
|
28
|
+
sort?: string
|
|
29
|
+
limit?: number
|
|
30
|
+
page?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── SWR Cache ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
interface CacheEntry<T> {
|
|
36
|
+
data: T
|
|
37
|
+
timestamp: number
|
|
38
|
+
etag?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PendingRequest {
|
|
42
|
+
promise: Promise<unknown>
|
|
43
|
+
controllers: AbortController[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stale-While-Revalidate cache.
|
|
48
|
+
* Returns stale data immediately, then revalidates in the background.
|
|
49
|
+
* Thread-safe for concurrent requests to the same key.
|
|
50
|
+
*/
|
|
51
|
+
class SWRCache {
|
|
52
|
+
private store = new Map<string, CacheEntry<unknown>>()
|
|
53
|
+
private pending = new Map<string, PendingRequest>()
|
|
54
|
+
private defaultTtl: number
|
|
55
|
+
|
|
56
|
+
constructor(defaultTtl = 30_000) {
|
|
57
|
+
this.defaultTtl = defaultTtl
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get<T>(key: string): { data: T; stale: boolean } | null {
|
|
61
|
+
const entry = this.store.get(key) as CacheEntry<T> | undefined
|
|
62
|
+
if (!entry) return null
|
|
63
|
+
const age = Date.now() - entry.timestamp
|
|
64
|
+
if (age > this.defaultTtl) {
|
|
65
|
+
return { data: entry.data, stale: true }
|
|
66
|
+
}
|
|
67
|
+
return { data: entry.data, stale: false }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
set<T>(key: string, data: T, etag?: string): void {
|
|
71
|
+
this.store.set(key, { data, timestamp: Date.now(), etag })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
invalidate(key: string): void {
|
|
75
|
+
this.store.delete(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
invalidateTag(tag: string): void {
|
|
79
|
+
// Invalidate all cache entries whose key contains the tag
|
|
80
|
+
for (const key of this.store.keys()) {
|
|
81
|
+
if (key.includes(tag)) this.store.delete(key)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
flush(): void {
|
|
86
|
+
this.store.clear()
|
|
87
|
+
for (const { controllers } of this.pending.values()) {
|
|
88
|
+
controllers.forEach((c) => c.abort())
|
|
89
|
+
}
|
|
90
|
+
this.pending.clear()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Error Handling ────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** Structured error thrown by all ZenithClient methods. */
|
|
97
|
+
export class ZenithAPIError extends Error {
|
|
98
|
+
readonly status: number
|
|
99
|
+
readonly code?: string
|
|
100
|
+
readonly isNetworkError: boolean
|
|
101
|
+
readonly isParseError: boolean
|
|
102
|
+
|
|
103
|
+
constructor(opts: {
|
|
104
|
+
message: string
|
|
105
|
+
status: number
|
|
106
|
+
code?: string
|
|
107
|
+
isNetworkError?: boolean
|
|
108
|
+
isParseError?: boolean
|
|
109
|
+
}) {
|
|
110
|
+
super(opts.message)
|
|
111
|
+
this.name = 'ZenithAPIError'
|
|
112
|
+
this.status = opts.status
|
|
113
|
+
this.code = opts.code
|
|
114
|
+
this.isNetworkError = opts.isNetworkError ?? false
|
|
115
|
+
this.isParseError = opts.isParseError ?? false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Batch Operation Request Descriptor ──────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
interface BatchRequest {
|
|
122
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
123
|
+
path: string
|
|
124
|
+
body?: unknown
|
|
125
|
+
headers?: Record<string, string>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Main Client ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
|
|
132
|
+
* Zero external dependencies — uses native browser fetch.
|
|
133
|
+
*/
|
|
134
|
+
export class ZenithClient {
|
|
135
|
+
private url: string
|
|
136
|
+
private apiKey?: string
|
|
137
|
+
private siteId?: string
|
|
138
|
+
private cache: SWRCache
|
|
139
|
+
|
|
140
|
+
constructor(options: ZenithClientOptions) {
|
|
141
|
+
this.url = options.url.replace(/\/$/, '')
|
|
142
|
+
this.apiKey = options.apiKey
|
|
143
|
+
this.siteId = options.siteId
|
|
144
|
+
this.cache = new SWRCache(options.cacheTtl ?? 30_000)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Cache control ───────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** Flush all cached responses. */
|
|
150
|
+
flushCache(): void {
|
|
151
|
+
this.cache.flush()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Set or update the active site ID. Also flushes the cache to prevent cross-tenant cached content leaks. */
|
|
155
|
+
setSiteId(siteId?: string): void {
|
|
156
|
+
this.siteId = siteId
|
|
157
|
+
this.flushCache()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Invalidate cache entries matching a tag. */
|
|
161
|
+
invalidateCache(tag: string): void {
|
|
162
|
+
this.cache.invalidateTag(tag)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private buildQueryString(options: FindOptions): string {
|
|
166
|
+
const params = new URLSearchParams()
|
|
167
|
+
|
|
168
|
+
if (options.locale) params.append('locale', options.locale)
|
|
169
|
+
if (options.depth !== undefined) params.append('depth', String(options.depth))
|
|
170
|
+
if (options.drafts) params.append('drafts', 'true')
|
|
171
|
+
if (options.sort) params.append('sort', options.sort)
|
|
172
|
+
if (options.limit !== undefined) params.append('limit', String(options.limit))
|
|
173
|
+
if (options.page !== undefined) params.append('page', String(options.page))
|
|
174
|
+
if (options.populate) {
|
|
175
|
+
const popStr = Array.isArray(options.populate) ? options.populate.join(',') : options.populate
|
|
176
|
+
params.append('populate', popStr)
|
|
177
|
+
}
|
|
178
|
+
if (options.select) {
|
|
179
|
+
const selStr = Array.isArray(options.select) ? options.select.join(',') : options.select
|
|
180
|
+
params.append('select', selStr)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (options.where) {
|
|
184
|
+
this.flattenWhereParams(options.where, 'where').forEach((value, key) => {
|
|
185
|
+
params.append(key, value)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const str = params.toString()
|
|
190
|
+
return str ? `?${str}` : ''
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private flattenWhereParams(obj: Record<string, any>, prefix: string): Map<string, string> {
|
|
194
|
+
const map = new Map<string, string>()
|
|
195
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
196
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
197
|
+
const nested = this.flattenWhereParams(value, `${prefix}[${key}]`)
|
|
198
|
+
nested.forEach((v, k) => map.set(k, v))
|
|
199
|
+
} else {
|
|
200
|
+
map.set(`${prefix}[${key}]`, String(value))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return map
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private buildHeaders(extra?: Record<string, string>): Headers {
|
|
207
|
+
const headers = new Headers(extra)
|
|
208
|
+
headers.set('Content-Type', 'application/json')
|
|
209
|
+
if (this.apiKey) headers.set('Authorization', `Bearer ${this.apiKey}`)
|
|
210
|
+
if (this.siteId) headers.set('X-Zenith-Site-Id', this.siteId)
|
|
211
|
+
return headers
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async fetchAPI(
|
|
215
|
+
path: string,
|
|
216
|
+
options: FetchOptions = {},
|
|
217
|
+
cacheKey?: string
|
|
218
|
+
): Promise<any> {
|
|
219
|
+
const headers = this.buildHeaders(options.headers as Record<string, string>)
|
|
220
|
+
|
|
221
|
+
// ── SWR: serve stale data immediately, revalidate in background ──────────
|
|
222
|
+
const useCache = options.cacheTtl !== 0 && cacheKey
|
|
223
|
+
const entry = useCache ? this.cache.get<unknown>(cacheKey) : null
|
|
224
|
+
|
|
225
|
+
if (entry && useCache) {
|
|
226
|
+
const revalidate = entry.stale && options.cacheTtl !== 0
|
|
227
|
+
|
|
228
|
+
if (revalidate) {
|
|
229
|
+
// Deduplicate: if a revalidation for this key is already in-flight, skip
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
231
|
+
const pending = (this.cache as any)['pending'] as Map<string, { promise: Promise<unknown>; controllers: AbortController[] }> | undefined
|
|
232
|
+
if (pending?.has(cacheKey!)) {
|
|
233
|
+
// revalidation already running for this key — don't spawn another
|
|
234
|
+
} else {
|
|
235
|
+
const ctrl = new AbortController()
|
|
236
|
+
const revalidatePromise = fetch(`${this.url}${path}`, {
|
|
237
|
+
...options,
|
|
238
|
+
headers,
|
|
239
|
+
signal: ctrl.signal,
|
|
240
|
+
})
|
|
241
|
+
.then((res) => {
|
|
242
|
+
if (res.ok) return res.json()
|
|
243
|
+
throw new ZenithAPIError({ message: `HTTP ${res.status}`, status: res.status })
|
|
244
|
+
})
|
|
245
|
+
.then((data) => {
|
|
246
|
+
this.cache.set(cacheKey!, data)
|
|
247
|
+
pending?.delete(cacheKey!)
|
|
248
|
+
})
|
|
249
|
+
.catch(() => {
|
|
250
|
+
// SWR revalidation failures are silent — stale data is acceptable
|
|
251
|
+
pending?.delete(cacheKey!)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
if (pending) {
|
|
255
|
+
pending.set(cacheKey!, { promise: revalidatePromise as Promise<unknown>, controllers: [ctrl] })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return entry.data as any
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// No cache or cache disabled — fetch normally
|
|
264
|
+
let response: Response
|
|
265
|
+
try {
|
|
266
|
+
response = await fetch(`${this.url}${path}`, { ...options, headers })
|
|
267
|
+
} catch (networkErr) {
|
|
268
|
+
throw new ZenithAPIError({
|
|
269
|
+
message: networkErr instanceof Error ? networkErr.message : 'Network error',
|
|
270
|
+
status: 0,
|
|
271
|
+
isNetworkError: true,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let data: any
|
|
276
|
+
try {
|
|
277
|
+
data = await response.json()
|
|
278
|
+
} catch {
|
|
279
|
+
throw new ZenithAPIError({
|
|
280
|
+
message: `Invalid JSON response from server (HTTP ${response.status})`,
|
|
281
|
+
status: response.status,
|
|
282
|
+
isParseError: true,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
throw new ZenithAPIError({
|
|
288
|
+
message: data?.message || `Zenith API error: ${response.status} ${response.statusText}`,
|
|
289
|
+
status: response.status,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (useCache) {
|
|
294
|
+
this.cache.set(cacheKey!, data)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return data
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private cacheKey(collection: string, path: string): string {
|
|
301
|
+
return `${collection}:${path}`
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Content API ─────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Find multiple documents in a collection.
|
|
308
|
+
* Uses SWR cache by default (30s TTL). Bypass with `cacheTtl: 0`.
|
|
309
|
+
*/
|
|
310
|
+
async find<C extends string>(
|
|
311
|
+
collection: C,
|
|
312
|
+
options: FindOptions = {}
|
|
313
|
+
): Promise<{ docs: DocType<C>[]; totalDocs: number; totalPages: number; page: number }> {
|
|
314
|
+
const qs = this.buildQueryString(options)
|
|
315
|
+
const cacheKey = (options.cacheTtl !== 0)
|
|
316
|
+
? (options.cacheTag
|
|
317
|
+
? this.cacheKey(collection, `/api/v1/${collection}${qs}`)
|
|
318
|
+
: `/api/v1/${collection}${qs}`)
|
|
319
|
+
: undefined
|
|
320
|
+
|
|
321
|
+
const data = await this.fetchAPI(
|
|
322
|
+
`/api/v1/${collection}${qs}`,
|
|
323
|
+
{ method: 'GET', ...options },
|
|
324
|
+
cacheKey
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
const docs = Array.isArray(data.docs)
|
|
328
|
+
? data.docs
|
|
329
|
+
: Array.isArray(data.data)
|
|
330
|
+
? data.data
|
|
331
|
+
: data.data?.docs || []
|
|
332
|
+
|
|
333
|
+
const totalDocs = data.totalDocs ?? data.meta?.pagination?.total ?? docs.length
|
|
334
|
+
const totalPages = data.totalPages ?? data.meta?.pagination?.totalPages ?? 1
|
|
335
|
+
const page = data.page ?? data.meta?.pagination?.page ?? 1
|
|
336
|
+
|
|
337
|
+
return { docs, totalDocs, totalPages, page, data: docs, ...data } as any
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Find a single document by ID.
|
|
342
|
+
* Uses SWR cache by default.
|
|
343
|
+
*/
|
|
344
|
+
async findById<C extends string>(collection: C, id: string, options: FetchOptions = {}): Promise<DocType<C>> {
|
|
345
|
+
const qs = this.buildQueryString(options)
|
|
346
|
+
const data = await this.fetchAPI(
|
|
347
|
+
`/api/v1/${collection}/${id}${qs}`,
|
|
348
|
+
{ method: 'GET', ...options },
|
|
349
|
+
`/api/v1/${collection}/${id}${qs}`
|
|
350
|
+
)
|
|
351
|
+
return data.data?.document || data.data || data
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Fetch a singleton configuration.
|
|
356
|
+
*/
|
|
357
|
+
async findGlobal<T = any>(slug: string, options: FetchOptions = {}): Promise<T> {
|
|
358
|
+
const qs = this.buildQueryString(options)
|
|
359
|
+
const data = await this.fetchAPI(
|
|
360
|
+
`/api/v1/globals/${slug}${qs}`,
|
|
361
|
+
{ method: 'GET', ...options },
|
|
362
|
+
`/api/v1/globals/${slug}${qs}`
|
|
363
|
+
)
|
|
364
|
+
return data.data?.document || data.data || data
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Create a new document in a collection.
|
|
369
|
+
* Invalidates cache for the target collection.
|
|
370
|
+
*/
|
|
371
|
+
async create<C extends string>(collection: C, payload: Partial<DocType<C>>, options: FetchOptions = {}): Promise<DocType<C>> {
|
|
372
|
+
const qs = this.buildQueryString(options)
|
|
373
|
+
const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
body: JSON.stringify(payload),
|
|
376
|
+
...options,
|
|
377
|
+
cacheTtl: 0, // writes never use cache
|
|
378
|
+
})
|
|
379
|
+
this.cache.invalidateTag(collection) // optimistic invalidation
|
|
380
|
+
return data.data || data
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Update an existing document.
|
|
385
|
+
* Invalidates cache for the target collection.
|
|
386
|
+
*/
|
|
387
|
+
async update<C extends string>(
|
|
388
|
+
collection: C,
|
|
389
|
+
id: string,
|
|
390
|
+
payload: Partial<DocType<C>>,
|
|
391
|
+
options: FetchOptions = {}
|
|
392
|
+
): Promise<DocType<C>> {
|
|
393
|
+
const qs = this.buildQueryString(options)
|
|
394
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
|
|
395
|
+
method: 'PATCH',
|
|
396
|
+
body: JSON.stringify(payload),
|
|
397
|
+
...options,
|
|
398
|
+
cacheTtl: 0,
|
|
399
|
+
})
|
|
400
|
+
this.cache.invalidateTag(collection)
|
|
401
|
+
return data.data?.document || data.data || data
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Delete a document.
|
|
406
|
+
* Invalidates cache for the target collection.
|
|
407
|
+
*/
|
|
408
|
+
async delete<C extends string>(collection: C, id: string, options: FetchOptions = {}): Promise<DocType<C>> {
|
|
409
|
+
const qs = this.buildQueryString(options)
|
|
410
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
|
|
411
|
+
method: 'DELETE',
|
|
412
|
+
...options,
|
|
413
|
+
cacheTtl: 0,
|
|
414
|
+
})
|
|
415
|
+
this.cache.invalidateTag(collection)
|
|
416
|
+
return data.data?.document || data.data || data
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Aggregation & Counts ────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Count documents matching a filter.
|
|
423
|
+
* Uses SWR cache.
|
|
424
|
+
*/
|
|
425
|
+
async count<C extends string>(collection: C, filter?: Record<string, any>): Promise<number> {
|
|
426
|
+
const params = new URLSearchParams()
|
|
427
|
+
if (filter) {
|
|
428
|
+
Object.entries(filter).forEach(([k, v]) => {
|
|
429
|
+
if (v !== undefined && v !== null) params.append(`where[${k}]`, String(v))
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
const paramStr = params.toString()
|
|
433
|
+
const qs = paramStr ? `?${paramStr}` : ''
|
|
434
|
+
const data = await this.fetchAPI(
|
|
435
|
+
`/api/v1/${collection}/count${qs}`,
|
|
436
|
+
{ method: 'GET', cacheTtl: 0 }
|
|
437
|
+
)
|
|
438
|
+
return typeof data.data?.count === 'number' ? data.data.count : (data.count ?? 0)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Run an aggregation pipeline on a collection.
|
|
443
|
+
* Sends the pipeline to a dedicated endpoint.
|
|
444
|
+
*/
|
|
445
|
+
async aggregate<C extends string>(
|
|
446
|
+
collection: C,
|
|
447
|
+
pipeline: Record<string, unknown>[]
|
|
448
|
+
): Promise<unknown[]> {
|
|
449
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/aggregate`, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
body: JSON.stringify({ pipeline }),
|
|
452
|
+
cacheTtl: 0,
|
|
453
|
+
})
|
|
454
|
+
return data.data?.results ?? data.results ?? data
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Batch Operations ────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Execute multiple API calls in a single round-trip (parallel).
|
|
461
|
+
* All requests fire concurrently; waits for all to settle.
|
|
462
|
+
* Returns an array of results in the same order as the input requests.
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* const [posts, authors] = await client.batch([
|
|
466
|
+
* { method: 'GET', path: '/api/v1/posts?limit=10' },
|
|
467
|
+
* { method: 'GET', path: '/api/v1/authors?limit=5' },
|
|
468
|
+
* ])
|
|
469
|
+
*/
|
|
470
|
+
async batch(requests: BatchRequest[]): Promise<any[]> {
|
|
471
|
+
const results = await Promise.all(
|
|
472
|
+
requests.map(async (req) => {
|
|
473
|
+
const headers = this.buildHeaders(req.headers)
|
|
474
|
+
try {
|
|
475
|
+
const response = await fetch(`${this.url}${req.path}`, {
|
|
476
|
+
method: req.method || 'GET',
|
|
477
|
+
headers,
|
|
478
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
479
|
+
})
|
|
480
|
+
const data = await response.json().catch(() => null)
|
|
481
|
+
if (!response.ok) {
|
|
482
|
+
throw new Error(data?.message || `Batch item failed: ${response.status}`)
|
|
483
|
+
}
|
|
484
|
+
return data.data ?? data
|
|
485
|
+
} catch (err) {
|
|
486
|
+
// Propagate errors so Promise.allSettled-like behavior is available via .catch
|
|
487
|
+
throw err
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
)
|
|
491
|
+
return results
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── File Upload ─────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Upload a file (image, video, PDF, etc.) to Zenith CMS media store.
|
|
498
|
+
*
|
|
499
|
+
* @param file - File or Blob from <input type="file"> or FileReader
|
|
500
|
+
* @param metadata - Optional alt text, focal point, folder
|
|
501
|
+
*/
|
|
502
|
+
async upload(
|
|
503
|
+
file: File | Blob,
|
|
504
|
+
metadata?: {
|
|
505
|
+
alt?: string
|
|
506
|
+
focalPoint?: { x: number; y: number }
|
|
507
|
+
folder?: string
|
|
508
|
+
}
|
|
509
|
+
): Promise<any> {
|
|
510
|
+
const formData = new FormData()
|
|
511
|
+
const fileName = 'name' in file ? (file as any).name : 'file'
|
|
512
|
+
formData.append('file', file, fileName)
|
|
513
|
+
|
|
514
|
+
// Attach focal point as JSON string (multer parses it server-side)
|
|
515
|
+
if (metadata?.focalPoint) {
|
|
516
|
+
formData.append('focalPoint', JSON.stringify(metadata.focalPoint))
|
|
517
|
+
}
|
|
518
|
+
if (metadata?.alt) {
|
|
519
|
+
formData.append('alt', metadata.alt)
|
|
520
|
+
}
|
|
521
|
+
if (metadata?.folder) {
|
|
522
|
+
formData.append('folder', metadata.folder)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const headers = this.buildHeaders()
|
|
526
|
+
// Remove Content-Type so fetch sets the correct multipart boundary
|
|
527
|
+
headers.delete('Content-Type')
|
|
528
|
+
|
|
529
|
+
const response = await fetch(`${this.url}/api/v1/upload`, {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
headers,
|
|
532
|
+
body: formData,
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
const data = await response.json().catch(() => null)
|
|
536
|
+
if (!response.ok) {
|
|
537
|
+
throw new Error(data?.message || `Upload failed: ${response.status}`)
|
|
538
|
+
}
|
|
539
|
+
return data.data ?? data
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Upload multiple files in parallel.
|
|
544
|
+
* Uses Promise.all for concurrent uploads.
|
|
545
|
+
*/
|
|
546
|
+
async uploadMany(
|
|
547
|
+
files: (File | Blob)[],
|
|
548
|
+
metadata?: Parameters<typeof this.upload>[1]
|
|
549
|
+
): Promise<any[]> {
|
|
550
|
+
return Promise.all(files.map((file) => this.upload(file, metadata)))
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function createClient(options: ZenithClientOptions): ZenithClient {
|
|
555
|
+
return new ZenithClient(options)
|
|
556
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"types": []
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["**/*.test.ts"]
|
|
17
|
+
}
|