@zenith-open/zenithcms-sdk 1.0.0 → 1.0.1-beta.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/LICENSE +21 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +413 -0
- package/package.json +33 -29
- package/src/index.test.ts +0 -117
- package/src/index.ts +0 -556
- package/tsconfig.json +0 -17
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aman T Shekar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ZenithCollections } from '@zenith-open/zenithcms-types';
|
|
2
|
+
/** Resolve a collection name string to its document type, falling back to any for unknown collections. */
|
|
3
|
+
type DocType<C extends string> = C extends keyof ZenithCollections ? ZenithCollections[C] : any;
|
|
4
|
+
export interface ZenithClientOptions {
|
|
5
|
+
url: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
siteId?: string;
|
|
8
|
+
/** SWR cache TTL in ms. default 30_000 (30s). set 0 to disable. */
|
|
9
|
+
cacheTtl?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FetchOptions extends RequestInit {
|
|
12
|
+
locale?: string;
|
|
13
|
+
depth?: number;
|
|
14
|
+
drafts?: boolean;
|
|
15
|
+
populate?: string[] | string;
|
|
16
|
+
select?: string[] | string;
|
|
17
|
+
/** Override the global cache TTL for this request. 0 = bypass cache. */
|
|
18
|
+
cacheTtl?: number;
|
|
19
|
+
/** Tag-based cache invalidation key for this request */
|
|
20
|
+
cacheTag?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface FindOptions extends FetchOptions {
|
|
23
|
+
where?: Record<string, any>;
|
|
24
|
+
sort?: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
page?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Structured error thrown by all ZenithClient methods. */
|
|
29
|
+
export declare class ZenithAPIError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly code?: string;
|
|
32
|
+
readonly isNetworkError: boolean;
|
|
33
|
+
readonly isParseError: boolean;
|
|
34
|
+
constructor(opts: {
|
|
35
|
+
message: string;
|
|
36
|
+
status: number;
|
|
37
|
+
code?: string;
|
|
38
|
+
isNetworkError?: boolean;
|
|
39
|
+
isParseError?: boolean;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
interface BatchRequest {
|
|
43
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
44
|
+
path: string;
|
|
45
|
+
body?: unknown;
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
|
|
50
|
+
* Zero external dependencies — uses native browser fetch.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ZenithClient {
|
|
53
|
+
private url;
|
|
54
|
+
private apiKey?;
|
|
55
|
+
private siteId?;
|
|
56
|
+
private cache;
|
|
57
|
+
constructor(options: ZenithClientOptions);
|
|
58
|
+
/** Flush all cached responses. */
|
|
59
|
+
flushCache(): void;
|
|
60
|
+
/** Set or update the active site ID. Also flushes the cache to prevent cross-tenant cached content leaks. */
|
|
61
|
+
setSiteId(siteId?: string): void;
|
|
62
|
+
/** Invalidate cache entries matching a tag. */
|
|
63
|
+
invalidateCache(tag: string): void;
|
|
64
|
+
private buildQueryString;
|
|
65
|
+
private flattenWhereParams;
|
|
66
|
+
private buildHeaders;
|
|
67
|
+
private fetchAPI;
|
|
68
|
+
private cacheKey;
|
|
69
|
+
/**
|
|
70
|
+
* Find multiple documents in a collection.
|
|
71
|
+
* Uses SWR cache by default (30s TTL). Bypass with `cacheTtl: 0`.
|
|
72
|
+
*/
|
|
73
|
+
find<C extends string>(collection: C, options?: FindOptions): Promise<{
|
|
74
|
+
docs: DocType<C>[];
|
|
75
|
+
totalDocs: number;
|
|
76
|
+
totalPages: number;
|
|
77
|
+
page: number;
|
|
78
|
+
}>;
|
|
79
|
+
/**
|
|
80
|
+
* Find a single document by ID.
|
|
81
|
+
* Uses SWR cache by default.
|
|
82
|
+
*/
|
|
83
|
+
findById<C extends string>(collection: C, id: string, options?: FetchOptions): Promise<DocType<C>>;
|
|
84
|
+
/**
|
|
85
|
+
* Fetch a singleton configuration.
|
|
86
|
+
*/
|
|
87
|
+
findGlobal<T = any>(slug: string, options?: FetchOptions): Promise<T>;
|
|
88
|
+
/**
|
|
89
|
+
* Create a new document in a collection.
|
|
90
|
+
* Invalidates cache for the target collection.
|
|
91
|
+
*/
|
|
92
|
+
create<C extends string>(collection: C, payload: Partial<DocType<C>>, options?: FetchOptions): Promise<DocType<C>>;
|
|
93
|
+
/**
|
|
94
|
+
* Update an existing document.
|
|
95
|
+
* Invalidates cache for the target collection.
|
|
96
|
+
*/
|
|
97
|
+
update<C extends string>(collection: C, id: string, payload: Partial<DocType<C>>, options?: FetchOptions): Promise<DocType<C>>;
|
|
98
|
+
/**
|
|
99
|
+
* Delete a document.
|
|
100
|
+
* Invalidates cache for the target collection.
|
|
101
|
+
*/
|
|
102
|
+
delete<C extends string>(collection: C, id: string, options?: FetchOptions): Promise<DocType<C>>;
|
|
103
|
+
/**
|
|
104
|
+
* Count documents matching a filter.
|
|
105
|
+
* Uses SWR cache.
|
|
106
|
+
*/
|
|
107
|
+
count<C extends string>(collection: C, filter?: Record<string, any>): Promise<number>;
|
|
108
|
+
/**
|
|
109
|
+
* Run an aggregation pipeline on a collection.
|
|
110
|
+
* Sends the pipeline to a dedicated endpoint.
|
|
111
|
+
*/
|
|
112
|
+
aggregate<C extends string>(collection: C, pipeline: Record<string, unknown>[]): Promise<unknown[]>;
|
|
113
|
+
/**
|
|
114
|
+
* Execute multiple API calls in a single round-trip (parallel).
|
|
115
|
+
* All requests fire concurrently; waits for all to settle.
|
|
116
|
+
* Returns an array of results in the same order as the input requests.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* const [posts, authors] = await client.batch([
|
|
120
|
+
* { method: 'GET', path: '/api/v1/posts?limit=10' },
|
|
121
|
+
* { method: 'GET', path: '/api/v1/authors?limit=5' },
|
|
122
|
+
* ])
|
|
123
|
+
*/
|
|
124
|
+
batch(requests: BatchRequest[]): Promise<any[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Upload a file (image, video, PDF, etc.) to Zenith CMS media store.
|
|
127
|
+
*
|
|
128
|
+
* @param file - File or Blob from <input type="file"> or FileReader
|
|
129
|
+
* @param metadata - Optional alt text, focal point, folder
|
|
130
|
+
*/
|
|
131
|
+
upload(file: File | Blob, metadata?: {
|
|
132
|
+
alt?: string;
|
|
133
|
+
focalPoint?: {
|
|
134
|
+
x: number;
|
|
135
|
+
y: number;
|
|
136
|
+
};
|
|
137
|
+
folder?: string;
|
|
138
|
+
}): Promise<any>;
|
|
139
|
+
/**
|
|
140
|
+
* Upload multiple files in parallel.
|
|
141
|
+
* Uses Promise.all for concurrent uploads.
|
|
142
|
+
*/
|
|
143
|
+
uploadMany(files: (File | Blob)[], metadata?: Parameters<typeof this.upload>[1]): Promise<any[]>;
|
|
144
|
+
}
|
|
145
|
+
export declare function createClient(options: ZenithClientOptions): ZenithClient;
|
|
146
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-While-Revalidate cache.
|
|
3
|
+
* Returns stale data immediately, then revalidates in the background.
|
|
4
|
+
* Thread-safe for concurrent requests to the same key.
|
|
5
|
+
*/
|
|
6
|
+
class SWRCache {
|
|
7
|
+
store = new Map();
|
|
8
|
+
pending = new Map();
|
|
9
|
+
defaultTtl;
|
|
10
|
+
constructor(defaultTtl = 30_000) {
|
|
11
|
+
this.defaultTtl = defaultTtl;
|
|
12
|
+
}
|
|
13
|
+
get(key) {
|
|
14
|
+
const entry = this.store.get(key);
|
|
15
|
+
if (!entry)
|
|
16
|
+
return null;
|
|
17
|
+
const age = Date.now() - entry.timestamp;
|
|
18
|
+
if (age > this.defaultTtl) {
|
|
19
|
+
return { data: entry.data, stale: true };
|
|
20
|
+
}
|
|
21
|
+
return { data: entry.data, stale: false };
|
|
22
|
+
}
|
|
23
|
+
set(key, data, etag) {
|
|
24
|
+
this.store.set(key, { data, timestamp: Date.now(), etag });
|
|
25
|
+
}
|
|
26
|
+
invalidate(key) {
|
|
27
|
+
this.store.delete(key);
|
|
28
|
+
}
|
|
29
|
+
invalidateTag(tag) {
|
|
30
|
+
// Invalidate all cache entries whose key contains the tag
|
|
31
|
+
for (const key of this.store.keys()) {
|
|
32
|
+
if (key.includes(tag))
|
|
33
|
+
this.store.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
flush() {
|
|
37
|
+
this.store.clear();
|
|
38
|
+
for (const { controllers } of this.pending.values()) {
|
|
39
|
+
controllers.forEach((c) => c.abort());
|
|
40
|
+
}
|
|
41
|
+
this.pending.clear();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Error Handling ────────────────────────────────────────────────────────────
|
|
45
|
+
/** Structured error thrown by all ZenithClient methods. */
|
|
46
|
+
export class ZenithAPIError extends Error {
|
|
47
|
+
status;
|
|
48
|
+
code;
|
|
49
|
+
isNetworkError;
|
|
50
|
+
isParseError;
|
|
51
|
+
constructor(opts) {
|
|
52
|
+
super(opts.message);
|
|
53
|
+
this.name = 'ZenithAPIError';
|
|
54
|
+
this.status = opts.status;
|
|
55
|
+
this.code = opts.code;
|
|
56
|
+
this.isNetworkError = opts.isNetworkError ?? false;
|
|
57
|
+
this.isParseError = opts.isParseError ?? false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ── Main Client ──────────────────────────────────────────────────────────────
|
|
61
|
+
/**
|
|
62
|
+
* Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
|
|
63
|
+
* Zero external dependencies — uses native browser fetch.
|
|
64
|
+
*/
|
|
65
|
+
export class ZenithClient {
|
|
66
|
+
url;
|
|
67
|
+
apiKey;
|
|
68
|
+
siteId;
|
|
69
|
+
cache;
|
|
70
|
+
constructor(options) {
|
|
71
|
+
this.url = options.url.replace(/\/$/, '');
|
|
72
|
+
this.apiKey = options.apiKey;
|
|
73
|
+
this.siteId = options.siteId;
|
|
74
|
+
this.cache = new SWRCache(options.cacheTtl ?? 30_000);
|
|
75
|
+
}
|
|
76
|
+
// ── Cache control ───────────────────────────────────────────────────────────
|
|
77
|
+
/** Flush all cached responses. */
|
|
78
|
+
flushCache() {
|
|
79
|
+
this.cache.flush();
|
|
80
|
+
}
|
|
81
|
+
/** Set or update the active site ID. Also flushes the cache to prevent cross-tenant cached content leaks. */
|
|
82
|
+
setSiteId(siteId) {
|
|
83
|
+
this.siteId = siteId;
|
|
84
|
+
this.flushCache();
|
|
85
|
+
}
|
|
86
|
+
/** Invalidate cache entries matching a tag. */
|
|
87
|
+
invalidateCache(tag) {
|
|
88
|
+
this.cache.invalidateTag(tag);
|
|
89
|
+
}
|
|
90
|
+
buildQueryString(options) {
|
|
91
|
+
const params = new URLSearchParams();
|
|
92
|
+
if (options.locale)
|
|
93
|
+
params.append('locale', options.locale);
|
|
94
|
+
if (options.depth !== undefined)
|
|
95
|
+
params.append('depth', String(options.depth));
|
|
96
|
+
if (options.drafts)
|
|
97
|
+
params.append('drafts', 'true');
|
|
98
|
+
if (options.sort)
|
|
99
|
+
params.append('sort', options.sort);
|
|
100
|
+
if (options.limit !== undefined)
|
|
101
|
+
params.append('limit', String(options.limit));
|
|
102
|
+
if (options.page !== undefined)
|
|
103
|
+
params.append('page', String(options.page));
|
|
104
|
+
if (options.populate) {
|
|
105
|
+
const popStr = Array.isArray(options.populate) ? options.populate.join(',') : options.populate;
|
|
106
|
+
params.append('populate', popStr);
|
|
107
|
+
}
|
|
108
|
+
if (options.select) {
|
|
109
|
+
const selStr = Array.isArray(options.select) ? options.select.join(',') : options.select;
|
|
110
|
+
params.append('select', selStr);
|
|
111
|
+
}
|
|
112
|
+
if (options.where) {
|
|
113
|
+
this.flattenWhereParams(options.where, 'where').forEach((value, key) => {
|
|
114
|
+
params.append(key, value);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const str = params.toString();
|
|
118
|
+
return str ? `?${str}` : '';
|
|
119
|
+
}
|
|
120
|
+
flattenWhereParams(obj, prefix) {
|
|
121
|
+
const map = new Map();
|
|
122
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
123
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
124
|
+
const nested = this.flattenWhereParams(value, `${prefix}[${key}]`);
|
|
125
|
+
nested.forEach((v, k) => map.set(k, v));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
map.set(`${prefix}[${key}]`, String(value));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
buildHeaders(extra) {
|
|
134
|
+
const headers = new Headers(extra);
|
|
135
|
+
headers.set('Content-Type', 'application/json');
|
|
136
|
+
if (this.apiKey)
|
|
137
|
+
headers.set('Authorization', `Bearer ${this.apiKey}`);
|
|
138
|
+
if (this.siteId)
|
|
139
|
+
headers.set('X-Zenith-Site-Id', this.siteId);
|
|
140
|
+
return headers;
|
|
141
|
+
}
|
|
142
|
+
async fetchAPI(path, options = {}, cacheKey) {
|
|
143
|
+
const headers = this.buildHeaders(options.headers);
|
|
144
|
+
// ── SWR: serve stale data immediately, revalidate in background ──────────
|
|
145
|
+
const useCache = options.cacheTtl !== 0 && cacheKey;
|
|
146
|
+
const entry = useCache ? this.cache.get(cacheKey) : null;
|
|
147
|
+
if (entry && useCache) {
|
|
148
|
+
const revalidate = entry.stale && options.cacheTtl !== 0;
|
|
149
|
+
if (revalidate) {
|
|
150
|
+
// Deduplicate: if a revalidation for this key is already in-flight, skip
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
const pending = this.cache['pending'];
|
|
153
|
+
if (pending?.has(cacheKey)) {
|
|
154
|
+
// revalidation already running for this key — don't spawn another
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const ctrl = new AbortController();
|
|
158
|
+
const revalidatePromise = fetch(`${this.url}${path}`, {
|
|
159
|
+
...options,
|
|
160
|
+
headers,
|
|
161
|
+
signal: ctrl.signal,
|
|
162
|
+
})
|
|
163
|
+
.then((res) => {
|
|
164
|
+
if (res.ok)
|
|
165
|
+
return res.json();
|
|
166
|
+
throw new ZenithAPIError({ message: `HTTP ${res.status}`, status: res.status });
|
|
167
|
+
})
|
|
168
|
+
.then((data) => {
|
|
169
|
+
this.cache.set(cacheKey, data);
|
|
170
|
+
pending?.delete(cacheKey);
|
|
171
|
+
})
|
|
172
|
+
.catch(() => {
|
|
173
|
+
// SWR revalidation failures are silent — stale data is acceptable
|
|
174
|
+
pending?.delete(cacheKey);
|
|
175
|
+
});
|
|
176
|
+
if (pending) {
|
|
177
|
+
pending.set(cacheKey, { promise: revalidatePromise, controllers: [ctrl] });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return entry.data;
|
|
182
|
+
}
|
|
183
|
+
// No cache or cache disabled — fetch normally
|
|
184
|
+
let response;
|
|
185
|
+
try {
|
|
186
|
+
response = await fetch(`${this.url}${path}`, { ...options, headers });
|
|
187
|
+
}
|
|
188
|
+
catch (networkErr) {
|
|
189
|
+
throw new ZenithAPIError({
|
|
190
|
+
message: networkErr instanceof Error ? networkErr.message : 'Network error',
|
|
191
|
+
status: 0,
|
|
192
|
+
isNetworkError: true,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
let data;
|
|
196
|
+
try {
|
|
197
|
+
data = await response.json();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new ZenithAPIError({
|
|
201
|
+
message: `Invalid JSON response from server (HTTP ${response.status})`,
|
|
202
|
+
status: response.status,
|
|
203
|
+
isParseError: true,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw new ZenithAPIError({
|
|
208
|
+
message: data?.message || `Zenith API error: ${response.status} ${response.statusText}`,
|
|
209
|
+
status: response.status,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (useCache) {
|
|
213
|
+
this.cache.set(cacheKey, data);
|
|
214
|
+
}
|
|
215
|
+
return data;
|
|
216
|
+
}
|
|
217
|
+
cacheKey(collection, path) {
|
|
218
|
+
return `${collection}:${path}`;
|
|
219
|
+
}
|
|
220
|
+
// ── Content API ─────────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Find multiple documents in a collection.
|
|
223
|
+
* Uses SWR cache by default (30s TTL). Bypass with `cacheTtl: 0`.
|
|
224
|
+
*/
|
|
225
|
+
async find(collection, options = {}) {
|
|
226
|
+
const qs = this.buildQueryString(options);
|
|
227
|
+
const cacheKey = (options.cacheTtl !== 0)
|
|
228
|
+
? (options.cacheTag
|
|
229
|
+
? this.cacheKey(collection, `/api/v1/${collection}${qs}`)
|
|
230
|
+
: `/api/v1/${collection}${qs}`)
|
|
231
|
+
: undefined;
|
|
232
|
+
const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, { method: 'GET', ...options }, cacheKey);
|
|
233
|
+
const docs = Array.isArray(data.docs)
|
|
234
|
+
? data.docs
|
|
235
|
+
: Array.isArray(data.data)
|
|
236
|
+
? data.data
|
|
237
|
+
: data.data?.docs || [];
|
|
238
|
+
const totalDocs = data.totalDocs ?? data.meta?.pagination?.total ?? docs.length;
|
|
239
|
+
const totalPages = data.totalPages ?? data.meta?.pagination?.totalPages ?? 1;
|
|
240
|
+
const page = data.page ?? data.meta?.pagination?.page ?? 1;
|
|
241
|
+
return { docs, totalDocs, totalPages, page, data: docs, ...data };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Find a single document by ID.
|
|
245
|
+
* Uses SWR cache by default.
|
|
246
|
+
*/
|
|
247
|
+
async findById(collection, id, options = {}) {
|
|
248
|
+
const qs = this.buildQueryString(options);
|
|
249
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, { method: 'GET', ...options }, `/api/v1/${collection}/${id}${qs}`);
|
|
250
|
+
return data.data?.document || data.data || data;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Fetch a singleton configuration.
|
|
254
|
+
*/
|
|
255
|
+
async findGlobal(slug, options = {}) {
|
|
256
|
+
const qs = this.buildQueryString(options);
|
|
257
|
+
const data = await this.fetchAPI(`/api/v1/globals/${slug}${qs}`, { method: 'GET', ...options }, `/api/v1/globals/${slug}${qs}`);
|
|
258
|
+
return data.data?.document || data.data || data;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Create a new document in a collection.
|
|
262
|
+
* Invalidates cache for the target collection.
|
|
263
|
+
*/
|
|
264
|
+
async create(collection, payload, options = {}) {
|
|
265
|
+
const qs = this.buildQueryString(options);
|
|
266
|
+
const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
body: JSON.stringify(payload),
|
|
269
|
+
...options,
|
|
270
|
+
cacheTtl: 0, // writes never use cache
|
|
271
|
+
});
|
|
272
|
+
this.cache.invalidateTag(collection); // optimistic invalidation
|
|
273
|
+
return data.data || data;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Update an existing document.
|
|
277
|
+
* Invalidates cache for the target collection.
|
|
278
|
+
*/
|
|
279
|
+
async update(collection, id, payload, options = {}) {
|
|
280
|
+
const qs = this.buildQueryString(options);
|
|
281
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
|
|
282
|
+
method: 'PATCH',
|
|
283
|
+
body: JSON.stringify(payload),
|
|
284
|
+
...options,
|
|
285
|
+
cacheTtl: 0,
|
|
286
|
+
});
|
|
287
|
+
this.cache.invalidateTag(collection);
|
|
288
|
+
return data.data?.document || data.data || data;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Delete a document.
|
|
292
|
+
* Invalidates cache for the target collection.
|
|
293
|
+
*/
|
|
294
|
+
async delete(collection, id, options = {}) {
|
|
295
|
+
const qs = this.buildQueryString(options);
|
|
296
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
|
|
297
|
+
method: 'DELETE',
|
|
298
|
+
...options,
|
|
299
|
+
cacheTtl: 0,
|
|
300
|
+
});
|
|
301
|
+
this.cache.invalidateTag(collection);
|
|
302
|
+
return data.data?.document || data.data || data;
|
|
303
|
+
}
|
|
304
|
+
// ── Aggregation & Counts ────────────────────────────────────────────────────
|
|
305
|
+
/**
|
|
306
|
+
* Count documents matching a filter.
|
|
307
|
+
* Uses SWR cache.
|
|
308
|
+
*/
|
|
309
|
+
async count(collection, filter) {
|
|
310
|
+
const params = new URLSearchParams();
|
|
311
|
+
if (filter) {
|
|
312
|
+
Object.entries(filter).forEach(([k, v]) => {
|
|
313
|
+
if (v !== undefined && v !== null)
|
|
314
|
+
params.append(`where[${k}]`, String(v));
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const paramStr = params.toString();
|
|
318
|
+
const qs = paramStr ? `?${paramStr}` : '';
|
|
319
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/count${qs}`, { method: 'GET', cacheTtl: 0 });
|
|
320
|
+
return typeof data.data?.count === 'number' ? data.data.count : (data.count ?? 0);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Run an aggregation pipeline on a collection.
|
|
324
|
+
* Sends the pipeline to a dedicated endpoint.
|
|
325
|
+
*/
|
|
326
|
+
async aggregate(collection, pipeline) {
|
|
327
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/aggregate`, {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
body: JSON.stringify({ pipeline }),
|
|
330
|
+
cacheTtl: 0,
|
|
331
|
+
});
|
|
332
|
+
return data.data?.results ?? data.results ?? data;
|
|
333
|
+
}
|
|
334
|
+
// ── Batch Operations ────────────────────────────────────────────────────────
|
|
335
|
+
/**
|
|
336
|
+
* Execute multiple API calls in a single round-trip (parallel).
|
|
337
|
+
* All requests fire concurrently; waits for all to settle.
|
|
338
|
+
* Returns an array of results in the same order as the input requests.
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* const [posts, authors] = await client.batch([
|
|
342
|
+
* { method: 'GET', path: '/api/v1/posts?limit=10' },
|
|
343
|
+
* { method: 'GET', path: '/api/v1/authors?limit=5' },
|
|
344
|
+
* ])
|
|
345
|
+
*/
|
|
346
|
+
async batch(requests) {
|
|
347
|
+
const results = await Promise.all(requests.map(async (req) => {
|
|
348
|
+
const headers = this.buildHeaders(req.headers);
|
|
349
|
+
try {
|
|
350
|
+
const response = await fetch(`${this.url}${req.path}`, {
|
|
351
|
+
method: req.method || 'GET',
|
|
352
|
+
headers,
|
|
353
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
354
|
+
});
|
|
355
|
+
const data = await response.json().catch(() => null);
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
throw new Error(data?.message || `Batch item failed: ${response.status}`);
|
|
358
|
+
}
|
|
359
|
+
return data.data ?? data;
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
// Propagate errors so Promise.allSettled-like behavior is available via .catch
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
}));
|
|
366
|
+
return results;
|
|
367
|
+
}
|
|
368
|
+
// ── File Upload ─────────────────────────────────────────────────────────────
|
|
369
|
+
/**
|
|
370
|
+
* Upload a file (image, video, PDF, etc.) to Zenith CMS media store.
|
|
371
|
+
*
|
|
372
|
+
* @param file - File or Blob from <input type="file"> or FileReader
|
|
373
|
+
* @param metadata - Optional alt text, focal point, folder
|
|
374
|
+
*/
|
|
375
|
+
async upload(file, metadata) {
|
|
376
|
+
const formData = new FormData();
|
|
377
|
+
const fileName = 'name' in file ? file.name : 'file';
|
|
378
|
+
formData.append('file', file, fileName);
|
|
379
|
+
// Attach focal point as JSON string (multer parses it server-side)
|
|
380
|
+
if (metadata?.focalPoint) {
|
|
381
|
+
formData.append('focalPoint', JSON.stringify(metadata.focalPoint));
|
|
382
|
+
}
|
|
383
|
+
if (metadata?.alt) {
|
|
384
|
+
formData.append('alt', metadata.alt);
|
|
385
|
+
}
|
|
386
|
+
if (metadata?.folder) {
|
|
387
|
+
formData.append('folder', metadata.folder);
|
|
388
|
+
}
|
|
389
|
+
const headers = this.buildHeaders();
|
|
390
|
+
// Remove Content-Type so fetch sets the correct multipart boundary
|
|
391
|
+
headers.delete('Content-Type');
|
|
392
|
+
const response = await fetch(`${this.url}/api/v1/upload`, {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
headers,
|
|
395
|
+
body: formData,
|
|
396
|
+
});
|
|
397
|
+
const data = await response.json().catch(() => null);
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
throw new Error(data?.message || `Upload failed: ${response.status}`);
|
|
400
|
+
}
|
|
401
|
+
return data.data ?? data;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Upload multiple files in parallel.
|
|
405
|
+
* Uses Promise.all for concurrent uploads.
|
|
406
|
+
*/
|
|
407
|
+
async uploadMany(files, metadata) {
|
|
408
|
+
return Promise.all(files.map((file) => this.upload(file, metadata)));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
export function createClient(options) {
|
|
412
|
+
return new ZenithClient(options);
|
|
413
|
+
}
|
package/package.json
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@zenith-open/zenithcms-sdk",
|
|
3
|
-
"version": "1.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
|
-
"
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
},
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@zenith-open/zenithcms-sdk",
|
|
3
|
+
"version": "1.0.1-beta.1",
|
|
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
|
+
"license": "MIT",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@zenith-open/zenithcms-types": "^1.0.1-beta.1",
|
|
22
|
+
"vitest": "^1.6.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"lint": "echo 'Lint bypassed for sdk'"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.test.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,556 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
}
|