@sovaria/sdk 0.0.0-stage → 0.4.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/dist/client.js ADDED
@@ -0,0 +1,583 @@
1
+ import { SovariaError } from "./types.js";
2
+ import { newTraceId, traceparentFor } from "./errors.js";
3
+ // ── Constants ────────────────────────────────────────────────────────────────
4
+ const MAX_RETRIES = 4;
5
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
6
+ const SAFE_RETRY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
7
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
8
+ const REFRESH_MARGIN_MS = 60_000;
9
+ function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+ function getHeaderCaseInsensitive(headers, headerName) {
13
+ const wanted = headerName.toLowerCase();
14
+ for (const [key, value] of Object.entries(headers)) {
15
+ if (key.toLowerCase() === wanted) {
16
+ return value;
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+ function generateIdempotencyKey() {
22
+ const cryptoObj = globalThis.crypto;
23
+ if (typeof cryptoObj?.randomUUID === "function") {
24
+ return cryptoObj.randomUUID();
25
+ }
26
+ if (typeof cryptoObj?.getRandomValues === "function") {
27
+ const bytes = new Uint8Array(16);
28
+ cryptoObj.getRandomValues(bytes);
29
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
30
+ }
31
+ throw SovariaError.config("No CSPRNG available for idempotency key generation", 500);
32
+ }
33
+ function existingIdempotencyKey(headers) {
34
+ const existing = getHeaderCaseInsensitive(headers, "Idempotency-Key")
35
+ ?? getHeaderCaseInsensitive(headers, "X-Idempotency-Key");
36
+ return existing?.trim() || undefined;
37
+ }
38
+ function parseRetryAfterMs(retryAfterHeader) {
39
+ if (!retryAfterHeader) {
40
+ return null;
41
+ }
42
+ const seconds = Number.parseFloat(retryAfterHeader);
43
+ if (Number.isFinite(seconds) && seconds >= 0) {
44
+ return Math.floor(seconds * 1000);
45
+ }
46
+ const retryAtMs = Date.parse(retryAfterHeader);
47
+ if (Number.isNaN(retryAtMs)) {
48
+ return null;
49
+ }
50
+ return Math.max(0, retryAtMs - Date.now());
51
+ }
52
+ function computeRetryDelayMs(attempt, retryAfterMs = null) {
53
+ const jitterBackoffMs = Math.min(10_000, 100 * Math.pow(2, attempt) * Math.random());
54
+ if (retryAfterMs === null) {
55
+ return jitterBackoffMs;
56
+ }
57
+ return Math.max(jitterBackoffMs, Math.min(60_000, retryAfterMs));
58
+ }
59
+ // ── SovariaClient ────────────────────────────────────────────────────────────
60
+ /**
61
+ * Async TypeScript client for the Sovaria API Gateway.
62
+ *
63
+ * @deprecated Use {@link SovariaSDK} from `./index`. This legacy client is retained
64
+ * for in-repo tests only and is not a public package export.
65
+ *
66
+ * All requests flow through the gateway; engines are never called directly.
67
+ * Token refresh is automatic and transparent.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const sov = new SovariaClient("https://api.sovaria.in");
72
+ * await sov.auth.login("user@example.com", "password"); // deprecated; posts to /console/login
73
+ * sov.setContext("org-123", "proj-456");
74
+ *
75
+ * const rows = await sov.data.sql.query("SELECT * FROM users LIMIT 10");
76
+ * await sov.auth.logout();
77
+ * ```
78
+ */
79
+ export class SovariaClient {
80
+ baseUrl;
81
+ accessToken;
82
+ refreshToken;
83
+ expiresAt = 0;
84
+ orgId = null;
85
+ projectId = null;
86
+ envId = null;
87
+ refreshPromise = null;
88
+ onTokenRefresh;
89
+ auth;
90
+ org;
91
+ data;
92
+ storage;
93
+ functions;
94
+ realtime;
95
+ chrono;
96
+ constructor(baseUrl, options) {
97
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
98
+ this.accessToken = options?.accessToken ?? null;
99
+ this.refreshToken = options?.refreshToken ?? null;
100
+ this.onTokenRefresh = options?.onTokenRefresh;
101
+ this.auth = new AuthModule(this);
102
+ this.org = new OrgModule(this);
103
+ this.data = new DataModule(this);
104
+ this.storage = new StorageModule(this);
105
+ this.functions = new FunctionsModule(this);
106
+ this.realtime = new RealtimeModule(this);
107
+ this.chrono = new ChronoModule(this);
108
+ }
109
+ /**
110
+ * Bind org/project context for multi-tenant headers.
111
+ * These are injected as `X-Sovaria-Org-ID` and `X-Sovaria-Project-ID`
112
+ * on every subsequent request.
113
+ */
114
+ setContext(orgId, projectId, envId) {
115
+ this.orgId = orgId;
116
+ this.projectId = projectId;
117
+ this.envId = envId?.trim() || null;
118
+ }
119
+ clearContext() {
120
+ this.orgId = null;
121
+ this.projectId = null;
122
+ this.envId = null;
123
+ }
124
+ // ── Token management (used internally by AuthModule) ───────────────────────
125
+ /** @internal */
126
+ _setTokens(tokens) {
127
+ this.accessToken = tokens.access_token;
128
+ this.refreshToken = tokens.refresh_token;
129
+ this.expiresAt = Date.now() + tokens.expires_in * 1000;
130
+ this.onTokenRefresh?.(tokens);
131
+ }
132
+ /** @internal */
133
+ _clearTokens() {
134
+ this.accessToken = null;
135
+ this.refreshToken = null;
136
+ this.expiresAt = 0;
137
+ }
138
+ /** @internal */
139
+ get _refreshToken() {
140
+ return this.refreshToken;
141
+ }
142
+ /** @internal */
143
+ get _isAuthenticated() {
144
+ return this.accessToken !== null;
145
+ }
146
+ // ── Core request method ────────────────────────────────────────────────────
147
+ /**
148
+ * Execute an HTTP request with automatic auth injection, token refresh, and
149
+ * exponential back-off with jitter on transient failures.
150
+ *
151
+ * @internal
152
+ */
153
+ async _request(method, path, options) {
154
+ const normalizedMethod = method.toUpperCase();
155
+ const requiresAuth = options?.requiresAuth ?? true;
156
+ const baseHeaders = { ...(options?.headers ?? {}) };
157
+ // One Idempotency-Key for every attempt of a write (the caller's, or a fresh one): if the first
158
+ // try was applied but its response was lost, the retry gets the stored answer instead of
159
+ // running the change twice.
160
+ const idempotencyKey = existingIdempotencyKey(baseHeaders) ??
161
+ (MUTATING_METHODS.has(normalizedMethod) ? generateIdempotencyKey() : undefined);
162
+ if (idempotencyKey) {
163
+ for (const name of Object.keys(baseHeaders)) {
164
+ if (name.toLowerCase() === "idempotency-key")
165
+ delete baseHeaders[name];
166
+ }
167
+ baseHeaders["Idempotency-Key"] = idempotencyKey;
168
+ }
169
+ const canRetry = SAFE_RETRY_METHODS.has(normalizedMethod) || MUTATING_METHODS.has(normalizedMethod);
170
+ const traceId = getHeaderCaseInsensitive(baseHeaders, "traceparent")?.split("-")[1] ?? newTraceId();
171
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
172
+ if (requiresAuth) {
173
+ await this.ensureTokenFresh();
174
+ }
175
+ const headers = {
176
+ Accept: "application/json",
177
+ ...(getHeaderCaseInsensitive(baseHeaders, "traceparent") ? {} : { traceparent: traceparentFor(traceId) }),
178
+ ...baseHeaders,
179
+ };
180
+ const contentType = headers["Content-Type"] ?? headers["content-type"];
181
+ const rawBody = Boolean(contentType && !contentType.includes("application/json")) ||
182
+ (typeof FormData !== "undefined" && options?.body instanceof FormData);
183
+ if (!rawBody && !contentType) {
184
+ headers["Content-Type"] = "application/json";
185
+ }
186
+ if (typeof FormData !== "undefined" && options?.body instanceof FormData) {
187
+ delete headers["Content-Type"];
188
+ delete headers["content-type"];
189
+ }
190
+ if (requiresAuth && this.accessToken) {
191
+ headers["Authorization"] = `Bearer ${this.accessToken}`;
192
+ }
193
+ if (this.orgId) {
194
+ headers["X-Sovaria-Org-ID"] = this.orgId;
195
+ }
196
+ if (this.projectId) {
197
+ headers["X-Sovaria-Project-ID"] = this.projectId;
198
+ }
199
+ if (this.envId) {
200
+ headers["X-Sovaria-Env-ID"] = this.envId;
201
+ }
202
+ let url = `${this.baseUrl}${path}`;
203
+ if (options?.params) {
204
+ const qs = new URLSearchParams(options.params).toString();
205
+ if (qs)
206
+ url += `?${qs}`;
207
+ }
208
+ const fetchInit = { method: normalizedMethod, headers };
209
+ if (options?.body !== undefined) {
210
+ fetchInit.body = rawBody
211
+ ? options.body
212
+ : JSON.stringify(options.body);
213
+ }
214
+ let response;
215
+ try {
216
+ response = await fetch(url, fetchInit);
217
+ }
218
+ catch (err) {
219
+ if (!canRetry || attempt >= MAX_RETRIES - 1) {
220
+ throw new SovariaError(0, `Network error: ${err instanceof Error ? err.message : String(err)}`);
221
+ }
222
+ if (attempt < MAX_RETRIES - 1) {
223
+ await sleep(computeRetryDelayMs(attempt));
224
+ continue;
225
+ }
226
+ }
227
+ if (!response) {
228
+ throw new SovariaError(0, "Network error: request failed before receiving a response");
229
+ }
230
+ if (response.ok) {
231
+ const text = await response.text();
232
+ if (!text)
233
+ return {};
234
+ try {
235
+ return JSON.parse(text);
236
+ }
237
+ catch {
238
+ return {};
239
+ }
240
+ }
241
+ // Auto-refresh on 401 and retry once
242
+ if (response.status === 401 && requiresAuth && this.refreshToken && attempt === 0) {
243
+ try {
244
+ await this.doRefresh();
245
+ continue;
246
+ }
247
+ catch {
248
+ // Refresh failed — fall through to error
249
+ }
250
+ }
251
+ let detail;
252
+ try {
253
+ detail = await response.json();
254
+ }
255
+ catch {
256
+ detail = response.statusText;
257
+ }
258
+ // "The same Idempotency-Key is still being processed": wait, then the retry gets the first
259
+ // try's stored answer instead of running the change twice.
260
+ const inProgress = response.status === 409 && detail?.code === "IDEMPOTENCY_IN_PROGRESS";
261
+ if (canRetry && (RETRYABLE_STATUSES.has(response.status) || inProgress) && attempt < MAX_RETRIES - 1) {
262
+ const retryAfterMs = response.status === 429 || inProgress
263
+ ? parseRetryAfterMs(response.headers.get("Retry-After"))
264
+ : null;
265
+ await sleep(computeRetryDelayMs(attempt, retryAfterMs));
266
+ continue;
267
+ }
268
+ throw new SovariaError(response.status, detail).withTraceId(response.headers.get("X-Request-ID") ?? traceId);
269
+ }
270
+ throw new SovariaError(0, "Max retries exceeded");
271
+ }
272
+ // ── Token refresh internals ────────────────────────────────────────────────
273
+ async ensureTokenFresh() {
274
+ if (!this.accessToken)
275
+ return;
276
+ if (Date.now() < this.expiresAt - REFRESH_MARGIN_MS)
277
+ return;
278
+ if (!this.refreshToken)
279
+ return;
280
+ // Deduplicate concurrent refresh calls
281
+ if (!this.refreshPromise) {
282
+ this.refreshPromise = this.doRefresh().finally(() => {
283
+ this.refreshPromise = null;
284
+ });
285
+ }
286
+ await this.refreshPromise;
287
+ }
288
+ async doRefresh() {
289
+ if (!this.refreshToken) {
290
+ throw new SovariaError(401, "No refresh token available — please log in again");
291
+ }
292
+ const response = await fetch(`${this.baseUrl}/api/v1/auth/refresh`, {
293
+ method: "POST",
294
+ headers: { "Content-Type": "application/json" },
295
+ body: JSON.stringify({ refresh_token: this.refreshToken }),
296
+ });
297
+ if (!response.ok) {
298
+ this._clearTokens();
299
+ let detail;
300
+ try {
301
+ const errBody = await response.json();
302
+ detail = errBody.detail ?? "Refresh failed";
303
+ }
304
+ catch {
305
+ detail = "Refresh failed";
306
+ }
307
+ throw new SovariaError(response.status, detail);
308
+ }
309
+ const tokens = await response.json();
310
+ this._setTokens(tokens);
311
+ }
312
+ /**
313
+ * Build a WebSocket URL from the base URL, converting http(s) to ws(s).
314
+ * @internal
315
+ */
316
+ _wsUrl(path) {
317
+ const base = this.baseUrl
318
+ .replace(/^https:\/\//, "wss://")
319
+ .replace(/^http:\/\//, "ws://");
320
+ return `${base}${path}`;
321
+ }
322
+ /** @internal */
323
+ async _issueRealtimeWsTicket(channel) {
324
+ if (!this.orgId || !this.projectId) {
325
+ throw SovariaError.config("Realtime websocket tickets require org/project context; call setContext() first");
326
+ }
327
+ const safeChannel = channel.trim();
328
+ if (!safeChannel) {
329
+ throw SovariaError.config("channel is required for realtime websocket tickets");
330
+ }
331
+ const response = await this._request("POST", "/api/v1/auth/ws-ticket", {
332
+ body: {
333
+ channel: safeChannel,
334
+ org_id: this.orgId,
335
+ project_id: this.projectId,
336
+ ...(this.envId ? { env_id: this.envId } : {}),
337
+ },
338
+ });
339
+ const ticket = response.ticket?.trim();
340
+ if (!ticket) {
341
+ throw new SovariaError(502, "Gateway did not return a websocket ticket");
342
+ }
343
+ return ticket;
344
+ }
345
+ /** @internal */
346
+ get _accessToken() {
347
+ return this.accessToken;
348
+ }
349
+ }
350
+ // ── Auth Module ──────────────────────────────────────────────────────────────
351
+ class AuthModule {
352
+ client;
353
+ constructor(client) {
354
+ this.client = client;
355
+ }
356
+ async login(email, password) {
357
+ console.warn("SovariaClient.auth.login() is deprecated; use SovariaSDK.auth.consoleLogin() or auth.projectLogin(). This legacy client posts to /api/v1/auth/console/login (not /api/v1/auth/login).");
358
+ const tokens = await this.client._request("POST", "/api/v1/auth/console/login", { body: { email, password }, requiresAuth: false });
359
+ this.client._setTokens(tokens);
360
+ return tokens;
361
+ }
362
+ async register(email, password, name) {
363
+ const tokens = await this.client._request("POST", "/api/v1/auth/register", { body: { email, password, name }, requiresAuth: false });
364
+ this.client._setTokens(tokens);
365
+ return tokens;
366
+ }
367
+ async logout() {
368
+ if (this.client._isAuthenticated && this.client._refreshToken) {
369
+ try {
370
+ await this.client._request("POST", "/api/v1/auth/logout", {
371
+ body: { refresh_token: this.client._refreshToken },
372
+ });
373
+ }
374
+ catch {
375
+ // Best-effort: local tokens are cleared regardless
376
+ }
377
+ }
378
+ this.client._clearTokens();
379
+ }
380
+ async me() {
381
+ return this.client._request("GET", "/api/v1/auth/me");
382
+ }
383
+ }
384
+ // ── Org Module ───────────────────────────────────────────────────────────────
385
+ class OrgModule {
386
+ client;
387
+ constructor(client) {
388
+ this.client = client;
389
+ }
390
+ async list() {
391
+ const result = await this.client._request("GET", "/api/v1/org");
392
+ return Array.isArray(result) ? result : result.nodes ?? [];
393
+ }
394
+ async get(id) {
395
+ return this.client._request("GET", `/api/v1/org/${encodeURIComponent(id)}`);
396
+ }
397
+ async create(type, name, metadata) {
398
+ return this.client._request("POST", "/api/v1/org", {
399
+ body: { type, name, metadata: metadata ?? {} },
400
+ });
401
+ }
402
+ async children(id) {
403
+ const result = await this.client._request("GET", `/api/v1/org/${encodeURIComponent(id)}/children`);
404
+ return Array.isArray(result) ? result : result.children ?? [];
405
+ }
406
+ }
407
+ // ── Data Module ──────────────────────────────────────────────────────────────
408
+ class SqlSubmodule {
409
+ client;
410
+ constructor(client) {
411
+ this.client = client;
412
+ }
413
+ async createTable(tableName, columns) {
414
+ return this.client._request("POST", "/api/v1/data/rel/create", {
415
+ body: { table_name: tableName, columns },
416
+ });
417
+ }
418
+ async query(sql, params) {
419
+ const result = await this.client._request("POST", "/api/v1/sql/query", { body: { sql, params: params ?? {} } });
420
+ return Array.isArray(result) ? result : result.rows ?? [];
421
+ }
422
+ async dropTable(name) {
423
+ return this.client._request("POST", "/api/v1/data/rel/drop", {
424
+ body: { table_name: name },
425
+ });
426
+ }
427
+ }
428
+ class DocSubmodule {
429
+ client;
430
+ constructor(client) {
431
+ this.client = client;
432
+ }
433
+ async put(collection, key, data) {
434
+ return this.client._request("POST", `/api/v1/data/doc/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`, { body: data });
435
+ }
436
+ async get(collection, key) {
437
+ return this.client._request("GET", `/api/v1/data/doc/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`);
438
+ }
439
+ async scan(collection, filter) {
440
+ return this.client._request("POST", "/api/v1/data/query/scan", {
441
+ body: { prefix: collection, filter: filter ?? {} },
442
+ });
443
+ }
444
+ }
445
+ class DataModule {
446
+ sql;
447
+ doc;
448
+ constructor(client) {
449
+ this.sql = new SqlSubmodule(client);
450
+ this.doc = new DocSubmodule(client);
451
+ }
452
+ }
453
+ // ── Storage Module ───────────────────────────────────────────────────────────
454
+ class StorageModule {
455
+ client;
456
+ constructor(client) {
457
+ this.client = client;
458
+ }
459
+ async listBuckets() {
460
+ const result = await this.client._request("GET", "/api/v1/store/buckets");
461
+ return Array.isArray(result) ? result : result.buckets ?? [];
462
+ }
463
+ async createBucket(name, isPublic = false) {
464
+ return this.client._request("POST", "/api/v1/store/buckets", {
465
+ body: { name, public: isPublic },
466
+ });
467
+ }
468
+ async listObjects(bucket) {
469
+ const result = await this.client._request("GET", `/api/v1/store/objects/${encodeURIComponent(bucket)}`);
470
+ return Array.isArray(result) ? result : result.objects ?? [];
471
+ }
472
+ async uploadObject(bucket, path, body, contentType = "application/octet-stream") {
473
+ const encodedPath = path
474
+ .split("/")
475
+ .map((s) => encodeURIComponent(s))
476
+ .join("/");
477
+ return this.client._request("POST", `/api/v1/store/objects/${encodeURIComponent(bucket)}/${encodedPath}`, {
478
+ body,
479
+ headers: { "Content-Type": contentType },
480
+ });
481
+ }
482
+ async deleteObject(bucket, path) {
483
+ const encodedPath = path
484
+ .split("/")
485
+ .map((s) => encodeURIComponent(s))
486
+ .join("/");
487
+ await this.client._request("DELETE", `/api/v1/store/objects/${encodeURIComponent(bucket)}/${encodedPath}`);
488
+ }
489
+ }
490
+ // ── Functions Module ─────────────────────────────────────────────────────────
491
+ class FunctionsModule {
492
+ client;
493
+ constructor(client) {
494
+ this.client = client;
495
+ }
496
+ async deploy(name, code, runtime, entrypoint) {
497
+ return this.client._request("POST", "/api/v1/fn/deploy", { body: { name, code, runtime, entrypoint } });
498
+ }
499
+ async invoke(name, payload) {
500
+ return this.client._request("POST", `/api/v1/fn/${encodeURIComponent(name)}/invoke`, { body: payload ?? {} });
501
+ }
502
+ }
503
+ // ── Realtime Module ──────────────────────────────────────────────────────────
504
+ class RealtimeModule {
505
+ client;
506
+ constructor(client) {
507
+ this.client = client;
508
+ }
509
+ /**
510
+ * Subscribe to a realtime channel via WebSocket.
511
+ *
512
+ * Uses a short-lived ticket so JWTs are never placed in WebSocket URLs.
513
+ *
514
+ * Returns a subscription handle with an `unsubscribe()` method.
515
+ */
516
+ subscribe(channel, callback) {
517
+ let ws = null;
518
+ let unsubscribed = false;
519
+ void (async () => {
520
+ try {
521
+ const ticket = await this.client._issueRealtimeWsTicket(channel);
522
+ if (unsubscribed) {
523
+ return;
524
+ }
525
+ const params = new URLSearchParams({ ticket });
526
+ const wsUrl = this.client._wsUrl(`/api/v1/rt/ws/${encodeURIComponent(channel)}?${params.toString()}`);
527
+ ws = new WebSocket(wsUrl);
528
+ ws.onmessage = (event) => {
529
+ try {
530
+ const parsed = JSON.parse(String(event.data));
531
+ callback(parsed);
532
+ }
533
+ catch {
534
+ callback({ type: "message", channel, payload: event.data });
535
+ }
536
+ };
537
+ ws.onerror = (event) => {
538
+ callback({
539
+ type: "error",
540
+ channel,
541
+ payload: event,
542
+ });
543
+ };
544
+ ws.onclose = (event) => {
545
+ callback({
546
+ type: "close",
547
+ channel,
548
+ payload: { code: event.code, reason: event.reason },
549
+ });
550
+ };
551
+ }
552
+ catch (error) {
553
+ callback({
554
+ type: "error",
555
+ channel,
556
+ payload: error,
557
+ });
558
+ }
559
+ })();
560
+ return {
561
+ unsubscribe: () => {
562
+ unsubscribed = true;
563
+ if (ws
564
+ && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
565
+ ws.close();
566
+ }
567
+ },
568
+ };
569
+ }
570
+ }
571
+ // ── Chrono Module ────────────────────────────────────────────────────────────
572
+ class ChronoModule {
573
+ client;
574
+ constructor(client) {
575
+ this.client = client;
576
+ }
577
+ async log(timelineName) {
578
+ return this.client._request("GET", `/api/v1/chrono/timelines/${encodeURIComponent(timelineName)}/history`);
579
+ }
580
+ async jump(timelineName, targetHex) {
581
+ return this.client._request("POST", `/api/v1/chrono/timelines/${encodeURIComponent(timelineName)}/jump`, { body: { target_hex: targetHex } });
582
+ }
583
+ }