dexie-cloud-sdk 0.1.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/index.mjs ADDED
@@ -0,0 +1,1017 @@
1
+ // src/types.ts
2
+ var DexieCloudError = class extends Error {
3
+ constructor(message, status, response) {
4
+ super(message);
5
+ this.status = status;
6
+ this.response = response;
7
+ this.name = "DexieCloudError";
8
+ }
9
+ };
10
+ var DexieCloudAuthError = class extends DexieCloudError {
11
+ constructor(message, status) {
12
+ super(message, status);
13
+ this.name = "DexieCloudAuthError";
14
+ }
15
+ };
16
+ var DexieCloudNetworkError = class extends DexieCloudError {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "DexieCloudNetworkError";
20
+ }
21
+ };
22
+
23
+ // src/adapters.ts
24
+ var FetchAdapter = class {
25
+ constructor(config, fetchImpl = globalThis.fetch) {
26
+ this.config = config;
27
+ this.fetchImpl = fetchImpl;
28
+ if (!this.fetchImpl) {
29
+ throw new Error("Fetch is not available. Please provide a fetch implementation.");
30
+ }
31
+ }
32
+ async fetch(url, options = {}) {
33
+ const { timeout = 3e4, debug } = this.config;
34
+ const controller = new AbortController();
35
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
36
+ const fetchOptions = {
37
+ ...options,
38
+ signal: controller.signal,
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ "User-Agent": "dexie-cloud-sdk",
42
+ ...options.headers
43
+ }
44
+ };
45
+ if (debug) {
46
+ console.log(`[DexieCloud] ${options.method || "GET"} ${url}`, fetchOptions);
47
+ }
48
+ try {
49
+ const response = await this.fetchImpl(url, fetchOptions);
50
+ if (debug) {
51
+ console.log(`[DexieCloud] Response ${response.status}`, response);
52
+ }
53
+ return response;
54
+ } catch (error) {
55
+ if (error instanceof Error && error.name === "AbortError") {
56
+ throw new Error(`Request timeout after ${timeout}ms`);
57
+ }
58
+ throw error;
59
+ } finally {
60
+ clearTimeout(timeoutId);
61
+ }
62
+ }
63
+ };
64
+ var NodeAdapter = class {
65
+ constructor(config) {
66
+ this.config = config;
67
+ }
68
+ async fetch(url, options = {}) {
69
+ const { default: fetch } = await import("node-fetch");
70
+ const adapter = new FetchAdapter(this.config, fetch);
71
+ return adapter.fetch(url, options);
72
+ }
73
+ };
74
+ function createAdapter(config) {
75
+ if (config.fetch) {
76
+ return new FetchAdapter(config, config.fetch);
77
+ }
78
+ if (typeof globalThis.fetch === "function") {
79
+ return new FetchAdapter(config);
80
+ }
81
+ if (typeof process !== "undefined" && process.versions?.node) {
82
+ return new NodeAdapter(config);
83
+ }
84
+ throw new Error("No suitable HTTP adapter found. Please provide a fetch implementation in config.");
85
+ }
86
+
87
+ // src/tson.ts
88
+ import {
89
+ TypesonSimplified,
90
+ builtInTypeDefs,
91
+ fileTypeDef
92
+ } from "dexie-cloud-common";
93
+ import { TypesonSimplified as TypesonSimplified2, builtInTypeDefs as builtInTypeDefs2 } from "dexie-cloud-common";
94
+ var sdkTypeDefs = {
95
+ // URL type support
96
+ URL: {
97
+ test: (val) => val instanceof URL,
98
+ replace: (url) => ({
99
+ $t: "URL",
100
+ href: url.href
101
+ }),
102
+ revive: ({ href }) => new URL(href)
103
+ }
104
+ };
105
+ var TSON = TypesonSimplified(
106
+ builtInTypeDefs,
107
+ fileTypeDef,
108
+ sdkTypeDefs
109
+ );
110
+ function stringify(value, space) {
111
+ return TSON.stringify(value, void 0, space);
112
+ }
113
+ function parse(text) {
114
+ return TSON.parse(text);
115
+ }
116
+
117
+ // src/http-utils.ts
118
+ async function parseResponse(response) {
119
+ const text = await response.text();
120
+ if (!text || text.trim() === "") {
121
+ return void 0;
122
+ }
123
+ try {
124
+ return TSON.parse(text);
125
+ } catch (error) {
126
+ throw new Error(`Failed to parse response: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ }
129
+ function stringifyBody(data) {
130
+ if (data === void 0 || data === null) {
131
+ return "";
132
+ }
133
+ try {
134
+ return TSON.stringify(data);
135
+ } catch (error) {
136
+ throw new Error(`Failed to stringify request body: ${error instanceof Error ? error.message : String(error)}`);
137
+ }
138
+ }
139
+
140
+ // src/auth.ts
141
+ var AuthManager = class {
142
+ constructor(config, http) {
143
+ this.config = config;
144
+ this.http = http;
145
+ }
146
+ get serviceUrl() {
147
+ return `${this.config.serviceUrl}/service`;
148
+ }
149
+ /**
150
+ * Step 1: Request OTP to be sent to email
151
+ * Returns otp_id needed for verification
152
+ */
153
+ async requestOTP(email, scopes = ["CREATE_DB"]) {
154
+ const response = await this.http.fetch(`${this.serviceUrl}/token`, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: stringifyBody({
158
+ grant_type: "otp",
159
+ email,
160
+ scopes
161
+ })
162
+ });
163
+ if (!response.ok) {
164
+ const error = await response.text();
165
+ throw new DexieCloudAuthError(
166
+ `Failed to request OTP: ${error}`,
167
+ response.status
168
+ );
169
+ }
170
+ const data = await parseResponse(response);
171
+ if (data.type !== "otp-sent" || !data.otp_id) {
172
+ throw new DexieCloudAuthError(`Unexpected response: ${JSON.stringify(data)}`);
173
+ }
174
+ return data.otp_id;
175
+ }
176
+ /**
177
+ * Step 2: Verify OTP and get access tokens
178
+ */
179
+ async verifyOTP(email, otpId, otp, scopes = ["CREATE_DB"]) {
180
+ const response = await this.http.fetch(`${this.serviceUrl}/token`, {
181
+ method: "POST",
182
+ headers: { "Content-Type": "application/json" },
183
+ body: stringifyBody({
184
+ grant_type: "otp",
185
+ email,
186
+ scopes,
187
+ otp_id: otpId,
188
+ otp
189
+ })
190
+ });
191
+ if (!response.ok) {
192
+ const error = await response.text();
193
+ throw new DexieCloudAuthError(
194
+ `Failed to verify OTP: ${error}`,
195
+ response.status
196
+ );
197
+ }
198
+ const data = await parseResponse(response);
199
+ if (data.type !== "tokens" || !data.accessToken) {
200
+ throw new DexieCloudAuthError(`Unexpected response: ${JSON.stringify(data)}`);
201
+ }
202
+ return {
203
+ accessToken: data.accessToken,
204
+ refreshToken: data.refreshToken,
205
+ userId: data.userId
206
+ };
207
+ }
208
+ /**
209
+ * Full OTP flow: request → verify → return tokens
210
+ * The getOTP callback should return the OTP code (e.g., from email)
211
+ */
212
+ async authenticateWithOTP(email, getOTP, scopes = ["CREATE_DB"]) {
213
+ const otpId = await this.requestOTP(email, scopes);
214
+ const otp = await getOTP();
215
+ return this.verifyOTP(email, otpId, otp, scopes);
216
+ }
217
+ /**
218
+ * Request OTP for database-specific operations
219
+ */
220
+ async requestDatabaseOTP(dbUrl, email) {
221
+ const response = await this.http.fetch(`${dbUrl}/token`, {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: stringifyBody({
225
+ grant_type: "otp-email",
226
+ email
227
+ })
228
+ });
229
+ if (!response.ok) {
230
+ const error = await response.text();
231
+ throw new DexieCloudAuthError(
232
+ `Failed to request database OTP: ${error}`,
233
+ response.status
234
+ );
235
+ }
236
+ }
237
+ /**
238
+ * Verify OTP for database-specific access
239
+ */
240
+ async verifyDatabaseOTP(dbUrl, email, otp) {
241
+ const response = await this.http.fetch(`${dbUrl}/token`, {
242
+ method: "POST",
243
+ headers: { "Content-Type": "application/json" },
244
+ body: stringifyBody({
245
+ grant_type: "otp-token",
246
+ email,
247
+ otp
248
+ })
249
+ });
250
+ if (!response.ok) {
251
+ const error = await response.text();
252
+ throw new DexieCloudAuthError(
253
+ `Failed to verify database OTP: ${error}`,
254
+ response.status
255
+ );
256
+ }
257
+ return parseResponse(response);
258
+ }
259
+ /**
260
+ * Full database authentication flow
261
+ */
262
+ async authenticateDatabase(dbUrl, email, getOTP) {
263
+ await this.requestDatabaseOTP(dbUrl, email);
264
+ const otp = await getOTP();
265
+ return this.verifyDatabaseOTP(dbUrl, email, otp);
266
+ }
267
+ /**
268
+ * Authenticate using client credentials (client_id + client_secret).
269
+ *
270
+ * This is the recommended method for server-to-server access.
271
+ * The credentials are found in the `dexie-cloud.key` file generated
272
+ * by the `dexie-cloud` CLI when connecting to a database.
273
+ *
274
+ * @param dbUrl - The database URL (e.g., 'https://xxxxxxxx.dexie.cloud')
275
+ * @param clientId - The client_id from dexie-cloud.key
276
+ * @param clientSecret - The client_secret from dexie-cloud.key
277
+ * @param scopes - Requested scopes (default: ['ACCESS_DB'])
278
+ */
279
+ async authenticateWithClientCredentials(dbUrl, clientId, clientSecret, scopes = ["ACCESS_DB"]) {
280
+ const response = await this.http.fetch(`${dbUrl}/token`, {
281
+ method: "POST",
282
+ headers: { "Content-Type": "application/json" },
283
+ body: stringifyBody({
284
+ grant_type: "client_credentials",
285
+ client_id: clientId,
286
+ client_secret: clientSecret,
287
+ scopes
288
+ })
289
+ });
290
+ if (!response.ok) {
291
+ const error = await response.text();
292
+ throw new DexieCloudAuthError(
293
+ `Failed to authenticate with client credentials: ${error}`,
294
+ response.status
295
+ );
296
+ }
297
+ const data = await parseResponse(response);
298
+ if (data.type !== "tokens" || !data.accessToken) {
299
+ throw new DexieCloudAuthError(`Unexpected response: ${JSON.stringify(data)}`);
300
+ }
301
+ return {
302
+ accessToken: data.accessToken,
303
+ refreshToken: data.refreshToken,
304
+ userId: data.userId
305
+ };
306
+ }
307
+ };
308
+
309
+ // src/database.ts
310
+ var DatabaseManager = class {
311
+ constructor(config, http) {
312
+ this.config = config;
313
+ this.http = http;
314
+ }
315
+ get serviceUrl() {
316
+ return `${this.config.serviceUrl}/service`;
317
+ }
318
+ /**
319
+ * Create a new database (requires authenticated token with CREATE_DB scope)
320
+ */
321
+ async create(accessToken, options = {}) {
322
+ const response = await this.http.fetch(`${this.serviceUrl}/create-db`, {
323
+ method: "POST",
324
+ headers: {
325
+ "Authorization": `Bearer ${accessToken}`,
326
+ "Content-Type": "application/json"
327
+ },
328
+ body: stringifyBody({
329
+ timeZone: options.timeZone || "UTC",
330
+ ...options.hackathon && { hackathon: true }
331
+ })
332
+ });
333
+ if (!response.ok) {
334
+ const error = await response.text();
335
+ throw new DexieCloudError(
336
+ `Failed to create database: ${error}`,
337
+ response.status
338
+ );
339
+ }
340
+ const data = await parseResponse(response);
341
+ if (!data?.url) {
342
+ throw new DexieCloudError(`Invalid response: ${JSON.stringify(data)}`);
343
+ }
344
+ return data;
345
+ }
346
+ /**
347
+ * List databases accessible to user (placeholder - API not documented yet)
348
+ */
349
+ async list(accessToken) {
350
+ throw new Error("List databases API not yet implemented");
351
+ }
352
+ /**
353
+ * Get database information by URL (placeholder)
354
+ */
355
+ async getInfo(dbUrl, accessToken) {
356
+ throw new Error("Database info API not yet implemented");
357
+ }
358
+ /**
359
+ * Delete database (placeholder - use with extreme caution!)
360
+ */
361
+ async delete(dbUrl, accessToken) {
362
+ throw new Error("Delete database API not yet implemented");
363
+ }
364
+ };
365
+
366
+ // src/health.ts
367
+ var HealthManager = class {
368
+ constructor(config, http) {
369
+ this.config = config;
370
+ this.http = http;
371
+ }
372
+ /**
373
+ * Health check - basic server status
374
+ */
375
+ async health() {
376
+ try {
377
+ const response = await this.http.fetch(`${this.config.serviceUrl}/health`);
378
+ return response.ok;
379
+ } catch {
380
+ return false;
381
+ }
382
+ }
383
+ /**
384
+ * Ready check - server + database connection
385
+ */
386
+ async ready() {
387
+ try {
388
+ const response = await this.http.fetch(`${this.config.serviceUrl}/ready`);
389
+ return response.ok;
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+ /**
395
+ * Combined health status
396
+ */
397
+ async status() {
398
+ const [healthy, ready] = await Promise.all([
399
+ this.health(),
400
+ this.ready()
401
+ ]);
402
+ return { healthy, ready };
403
+ }
404
+ /**
405
+ * Wait for server to be ready
406
+ */
407
+ async waitForReady(timeout = 6e4, interval = 1e3) {
408
+ const startTime = Date.now();
409
+ while (Date.now() - startTime < timeout) {
410
+ if (await this.ready()) {
411
+ return;
412
+ }
413
+ await new Promise((resolve) => setTimeout(resolve, interval));
414
+ }
415
+ throw new DexieCloudNetworkError(`Server not ready after ${timeout}ms`);
416
+ }
417
+ /**
418
+ * Wait for server to be healthy
419
+ */
420
+ async waitForHealth(timeout = 3e4, interval = 1e3) {
421
+ const startTime = Date.now();
422
+ while (Date.now() - startTime < timeout) {
423
+ if (await this.health()) {
424
+ return;
425
+ }
426
+ await new Promise((resolve) => setTimeout(resolve, interval));
427
+ }
428
+ throw new DexieCloudNetworkError(`Server not healthy after ${timeout}ms`);
429
+ }
430
+ };
431
+
432
+ // src/data.ts
433
+ async function handleResponse(response) {
434
+ if (!response.ok) {
435
+ const text = await response.text().catch(() => "");
436
+ throw new DexieCloudError(
437
+ `HTTP ${response.status}: ${text || response.statusText}`,
438
+ response.status,
439
+ text
440
+ );
441
+ }
442
+ return parseResponse(response);
443
+ }
444
+ var DataManager = class {
445
+ constructor(dbUrl, http, blobManager, access = "my") {
446
+ this.dbUrl = dbUrl;
447
+ this.http = http;
448
+ this.blobManager = blobManager;
449
+ this.access = access;
450
+ }
451
+ tableUrl(table) {
452
+ return `${this.dbUrl}/${this.access}/${encodeURIComponent(table)}`;
453
+ }
454
+ itemUrl(table, id) {
455
+ return `${this.dbUrl}/${this.access}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`;
456
+ }
457
+ /**
458
+ * List all objects in a table, optionally filtered by realm.
459
+ * BlobRefs in results are automatically resolved to inline data
460
+ * when a BlobManager is present.
461
+ */
462
+ async list(table, token, options) {
463
+ let url = this.tableUrl(table);
464
+ if (options?.realm) {
465
+ url += `?realm=${encodeURIComponent(options.realm)}`;
466
+ }
467
+ const response = await this.http.fetch(url, {
468
+ method: "GET",
469
+ headers: { Authorization: `Bearer ${token}` }
470
+ });
471
+ const result = await handleResponse(response);
472
+ const items = Array.isArray(result) ? result : result?.data ?? result ?? [];
473
+ if (this.blobManager) {
474
+ const resolved = [];
475
+ for (const item of items) {
476
+ resolved.push(await this.blobManager.processForRead(item, token));
477
+ }
478
+ return resolved;
479
+ }
480
+ return items;
481
+ }
482
+ /**
483
+ * Get a single object by id.
484
+ * BlobRefs in the result are automatically resolved to inline data
485
+ * when a BlobManager is present.
486
+ */
487
+ async get(table, id, token) {
488
+ const url = this.itemUrl(table, id);
489
+ const response = await this.http.fetch(url, {
490
+ method: "GET",
491
+ headers: { Authorization: `Bearer ${token}` }
492
+ });
493
+ const result = await handleResponse(response);
494
+ if (this.blobManager) {
495
+ return this.blobManager.processForRead(result, token);
496
+ }
497
+ return result;
498
+ }
499
+ /**
500
+ * Create an object in a table.
501
+ * Inline blobs in obj are automatically uploaded and replaced with
502
+ * BlobRefs when a BlobManager is present.
503
+ */
504
+ async create(table, obj, token) {
505
+ const url = this.tableUrl(table);
506
+ const body = this.blobManager ? await this.blobManager.processForUpload(obj, token) : obj;
507
+ const response = await this.http.fetch(url, {
508
+ method: "POST",
509
+ headers: {
510
+ Authorization: `Bearer ${token}`,
511
+ "Content-Type": "application/json"
512
+ },
513
+ body: stringifyBody(body)
514
+ });
515
+ const keys = await handleResponse(response);
516
+ const id = Array.isArray(keys) ? keys[0] : keys;
517
+ return { id: typeof id === "string" ? id : JSON.stringify(id) };
518
+ }
519
+ /**
520
+ * Replace (full update) an object by id using HTTP PUT.
521
+ *
522
+ * NOTE: This sends the complete object as a full replacement (PUT semantics).
523
+ * All fields not included in `obj` will be removed on the server.
524
+ * If you want partial updates, use a PATCH-based approach when the server
525
+ * supports it (not currently exposed here).
526
+ *
527
+ * Previously named `update()` — `update` is kept as an alias for backwards
528
+ * compatibility but may be deprecated in a future release.
529
+ */
530
+ async replace(table, id, obj, token) {
531
+ const url = this.tableUrl(table);
532
+ const merged = { ...obj, id };
533
+ const body = this.blobManager ? await this.blobManager.processForUpload(merged, token) : merged;
534
+ const response = await this.http.fetch(url, {
535
+ method: "POST",
536
+ headers: {
537
+ Authorization: `Bearer ${token}`,
538
+ "Content-Type": "application/json"
539
+ },
540
+ body: stringifyBody(body)
541
+ });
542
+ const keys = await handleResponse(response);
543
+ const returnedId = Array.isArray(keys) ? keys[0] : keys;
544
+ return { id: typeof returnedId === "string" ? returnedId : JSON.stringify(returnedId) };
545
+ }
546
+ /**
547
+ * @deprecated Use `replace()` instead. This method performs a full object
548
+ * replacement (HTTP PUT), not a partial update (PATCH). The name `update`
549
+ * is misleading and kept only for backwards compatibility.
550
+ */
551
+ update(table, id, obj, token) {
552
+ return this.replace(table, id, obj, token);
553
+ }
554
+ /**
555
+ * Delete an object by id.
556
+ */
557
+ async delete(table, id, token) {
558
+ const url = this.itemUrl(table, id);
559
+ const response = await this.http.fetch(url, {
560
+ method: "DELETE",
561
+ headers: { Authorization: `Bearer ${token}` }
562
+ });
563
+ if (!response.ok) {
564
+ const text = await response.text().catch(() => "");
565
+ throw new DexieCloudError(
566
+ `HTTP ${response.status}: ${text || response.statusText}`,
567
+ response.status,
568
+ text
569
+ );
570
+ }
571
+ }
572
+ /**
573
+ * Bulk create objects in a table in parallel.
574
+ * All requests are sent concurrently via Promise.all.
575
+ *
576
+ * NOTE: This is NOT atomic — if one create fails, others may have already
577
+ * succeeded on the server. There is no rollback for partial failures.
578
+ */
579
+ async bulkCreate(table, objects, token) {
580
+ return Promise.all(objects.map((obj) => this.create(table, obj, token)));
581
+ }
582
+ };
583
+
584
+ // src/blob.ts
585
+ var BLOB_THRESHOLD = 4096;
586
+ var MAX_CONCURRENT_DOWNLOADS = 6;
587
+ function generateBlobId() {
588
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
589
+ return crypto.randomUUID().replace(/-/g, "");
590
+ }
591
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
592
+ const bytes = new Uint8Array(16);
593
+ crypto.getRandomValues(bytes);
594
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
595
+ }
596
+ const ts = Date.now().toString(16);
597
+ const rand = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
598
+ return ts + rand;
599
+ }
600
+ async function toUint8Array(data) {
601
+ if (data instanceof Uint8Array) return data;
602
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
603
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
604
+ const buf = await data.arrayBuffer();
605
+ return new Uint8Array(buf);
606
+ }
607
+ if (ArrayBuffer.isView(data)) {
608
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
609
+ }
610
+ throw new TypeError("Unsupported data type for blob upload");
611
+ }
612
+ function isInlineBlob(val) {
613
+ return val !== null && typeof val === "object" && typeof val._bt === "string" && typeof val.v === "string";
614
+ }
615
+ function isBlobRef(val) {
616
+ return val !== null && typeof val === "object" && typeof val._bt === "string" && typeof val.ref === "string" && val.v === void 0;
617
+ }
618
+ function base64ToUint8Array(b64) {
619
+ if (typeof Buffer !== "undefined") {
620
+ return new Uint8Array(Buffer.from(b64, "base64"));
621
+ }
622
+ const binary = atob(b64);
623
+ const bytes = new Uint8Array(binary.length);
624
+ for (let i = 0; i < binary.length; i++) {
625
+ bytes[i] = binary.charCodeAt(i);
626
+ }
627
+ return bytes;
628
+ }
629
+ function uint8ArrayToBase64(data) {
630
+ if (typeof Buffer !== "undefined") {
631
+ return Buffer.from(data).toString("base64");
632
+ }
633
+ let binary = "";
634
+ for (let i = 0; i < data.length; i++) {
635
+ binary += String.fromCharCode(data[i]);
636
+ }
637
+ return btoa(binary);
638
+ }
639
+ var DEFAULT_MAX_STRING_LENGTH = 32768;
640
+ var BlobManager = class {
641
+ constructor(dbUrl, http, mode = "auto", maxStringLength = DEFAULT_MAX_STRING_LENGTH) {
642
+ this.dbUrl = dbUrl;
643
+ this.http = http;
644
+ this.mode = mode;
645
+ this.maxStringLength = maxStringLength;
646
+ }
647
+ /**
648
+ * Upload binary data to the blob store.
649
+ * Returns the blob ref (e.g. "1:abc123...").
650
+ */
651
+ async upload(data, contentType = "application/octet-stream", token) {
652
+ const blobId = generateBlobId();
653
+ const url = `${this.dbUrl}/blob/${blobId}`;
654
+ const bytes = await toUint8Array(data);
655
+ const response = await this.http.fetch(url, {
656
+ method: "PUT",
657
+ headers: {
658
+ Authorization: `Bearer ${token}`,
659
+ "Content-Type": contentType
660
+ },
661
+ body: bytes
662
+ });
663
+ if (!response.ok) {
664
+ const text2 = await response.text().catch(() => "");
665
+ throw new DexieCloudError(
666
+ `Blob upload failed HTTP ${response.status}: ${text2 || response.statusText}`,
667
+ response.status,
668
+ text2
669
+ );
670
+ }
671
+ const text = await response.text().catch(() => "");
672
+ if (text && text.trim()) {
673
+ try {
674
+ const parsed = JSON.parse(text);
675
+ if (parsed?.ref) return parsed.ref;
676
+ } catch {
677
+ }
678
+ if (text.includes(":")) return text.trim();
679
+ }
680
+ throw new DexieCloudError(
681
+ `Blob upload succeeded (HTTP ${response.status}) but server returned no parseable ref. Cannot construct a safe blob reference without the server-assigned version.`,
682
+ response.status,
683
+ text
684
+ );
685
+ }
686
+ /**
687
+ * Download a blob by ref (format: "version:blobId").
688
+ */
689
+ async download(ref, token) {
690
+ const url = `${this.dbUrl}/blob/${encodeURIComponent(ref)}`;
691
+ const response = await this.http.fetch(url, {
692
+ method: "GET",
693
+ headers: { Authorization: `Bearer ${token}` }
694
+ });
695
+ if (!response.ok) {
696
+ const text = await response.text().catch(() => "");
697
+ throw new DexieCloudError(
698
+ `Blob download failed HTTP ${response.status}: ${text || response.statusText}`,
699
+ response.status,
700
+ text
701
+ );
702
+ }
703
+ const contentType = response.headers?.get("content-type") ?? "application/octet-stream";
704
+ const arrayBuffer = await response.arrayBuffer();
705
+ return { data: new Uint8Array(arrayBuffer), contentType };
706
+ }
707
+ /**
708
+ * Process an object before uploading: find inline blobs large enough to
709
+ * offload (≥ BLOB_THRESHOLD bytes), upload them, replace with BlobRefs.
710
+ * Small binaries are left inline. Only active in 'auto' mode.
711
+ */
712
+ async processForUpload(obj, token) {
713
+ if (this.mode !== "auto") return obj;
714
+ return this._walkForUpload(obj, token);
715
+ }
716
+ /**
717
+ * Process an object after reading: find BlobRefs, download them,
718
+ * replace with inline data. Only active in 'auto' mode.
719
+ */
720
+ async processForRead(obj, token) {
721
+ if (this.mode !== "auto") return obj;
722
+ return this._walkForRead(obj, token);
723
+ }
724
+ async _walkForUpload(val, token) {
725
+ if (typeof val === "string" && val.length > this.maxStringLength && this.maxStringLength !== Infinity) {
726
+ const bytes = new TextEncoder().encode(val);
727
+ const ref = await this.upload(bytes, "text/plain;charset=utf-8", token);
728
+ return { _bt: "string", ref, size: bytes.length };
729
+ }
730
+ if (isInlineBlob(val)) {
731
+ const bytes = base64ToUint8Array(val.v);
732
+ if (bytes.length < BLOB_THRESHOLD) {
733
+ return val;
734
+ }
735
+ const contentType = val.ct ?? "application/octet-stream";
736
+ const ref = await this.upload(bytes, contentType, token);
737
+ const blobRef = {
738
+ _bt: val._bt,
739
+ ref,
740
+ size: bytes.length,
741
+ ...val._bt === "Blob" && val.ct ? { ct: val.ct } : {}
742
+ };
743
+ return blobRef;
744
+ }
745
+ if (Array.isArray(val)) {
746
+ return Promise.all(val.map((item) => this._walkForUpload(item, token)));
747
+ }
748
+ if (val !== null && typeof val === "object") {
749
+ const entries = await Promise.all(
750
+ Object.entries(val).map(async ([k, v]) => [k, await this._walkForUpload(v, token)])
751
+ );
752
+ return Object.fromEntries(entries);
753
+ }
754
+ return val;
755
+ }
756
+ async _walkForRead(val, token) {
757
+ if (isBlobRef(val)) {
758
+ if (val._bt === "string") {
759
+ const { data: data2 } = await this.download(val.ref, token);
760
+ return new TextDecoder().decode(data2);
761
+ }
762
+ const { data, contentType } = await this.download(val.ref, token);
763
+ return {
764
+ _bt: val._bt,
765
+ v: uint8ArrayToBase64(data),
766
+ ...val._bt === "Blob" ? { ct: val.ct ?? contentType } : {}
767
+ };
768
+ }
769
+ if (Array.isArray(val)) {
770
+ return this._parallelMap(val, (item) => this._walkForRead(item, token));
771
+ }
772
+ if (val !== null && typeof val === "object") {
773
+ const keys = Object.keys(val);
774
+ const resolvedValues = await this._parallelMap(
775
+ keys,
776
+ (k) => this._walkForRead(val[k], token)
777
+ );
778
+ const result = {};
779
+ for (let i = 0; i < keys.length; i++) {
780
+ result[keys[i]] = resolvedValues[i];
781
+ }
782
+ return result;
783
+ }
784
+ return val;
785
+ }
786
+ /**
787
+ * Like Promise.all but with a concurrency cap.
788
+ */
789
+ async _parallelMap(items, fn) {
790
+ const results = new Array(items.length);
791
+ let index = 0;
792
+ async function worker() {
793
+ while (index < items.length) {
794
+ const i = index++;
795
+ results[i] = await fn(items[i]);
796
+ }
797
+ }
798
+ const workers = Array.from(
799
+ { length: Math.min(MAX_CONCURRENT_DOWNLOADS, items.length) },
800
+ () => worker()
801
+ );
802
+ await Promise.all(workers);
803
+ return results;
804
+ }
805
+ };
806
+
807
+ // src/DatabaseSession.ts
808
+ function stableStringify(obj) {
809
+ const sorted = Object.keys(obj).sort().reduce(
810
+ (acc, key) => {
811
+ acc[key] = obj[key];
812
+ return acc;
813
+ },
814
+ {}
815
+ );
816
+ return JSON.stringify(sorted);
817
+ }
818
+ var tokenCache = /* @__PURE__ */ new Map();
819
+ var REFRESH_MARGIN = 300;
820
+ function cacheKey(clientId, dbUrl, claims) {
821
+ return `${clientId}::${dbUrl}::${stableStringify(claims ?? {})}`;
822
+ }
823
+ function parseJwtExp(token) {
824
+ const payload = JSON.parse(
825
+ Buffer.from(token.split(".")[1], "base64url").toString()
826
+ );
827
+ return payload.exp;
828
+ }
829
+ function createDataProxy(data, getToken) {
830
+ return new Proxy(data, {
831
+ get(target, prop) {
832
+ const original = target[prop];
833
+ if (typeof original !== "function") return original;
834
+ return async (...args) => {
835
+ const token = await getToken();
836
+ return original.apply(target, [...args, token]);
837
+ };
838
+ }
839
+ });
840
+ }
841
+ function createBlobProxy(blobs, getToken) {
842
+ return new Proxy(blobs, {
843
+ get(target, prop) {
844
+ const original = target[prop];
845
+ if (typeof original !== "function") return original;
846
+ return async (...args) => {
847
+ const token = await getToken();
848
+ return original.apply(target, [...args, token]);
849
+ };
850
+ }
851
+ });
852
+ }
853
+ var DatabaseSession = class _DatabaseSession {
854
+ constructor(dbUrl, credentials, http, blobManager, dataManager) {
855
+ this.dbUrl = dbUrl;
856
+ this.credentials = credentials;
857
+ this.http = http;
858
+ this.inflightToken = null;
859
+ const getToken = () => this.getToken();
860
+ this.data = createDataProxy(dataManager, getToken);
861
+ this.blobs = createBlobProxy(blobManager, getToken);
862
+ }
863
+ /**
864
+ * Create a new session that impersonates a specific user.
865
+ *
866
+ * The returned session shares the same HTTP adapter and managers but
867
+ * uses a separate cache entry for tokens.
868
+ */
869
+ asUser(claims) {
870
+ const bm = new BlobManager(this.dbUrl, this.http);
871
+ return new _DatabaseSession(
872
+ this.dbUrl,
873
+ { ...this.credentials, impersonate: claims },
874
+ this.http,
875
+ bm,
876
+ new DataManager(this.dbUrl, this.http, bm, "my")
877
+ );
878
+ }
879
+ // ── Token management ────────────────────────────────────────────
880
+ async getToken() {
881
+ const key = cacheKey(
882
+ this.credentials.clientId,
883
+ this.dbUrl,
884
+ this.credentials.impersonate
885
+ );
886
+ const cached = tokenCache.get(key);
887
+ const now = Math.floor(Date.now() / 1e3);
888
+ if (cached && cached.expiresAt - now > REFRESH_MARGIN) {
889
+ return cached.token;
890
+ }
891
+ if (!this.inflightToken) {
892
+ this.inflightToken = this.fetchToken(key).finally(() => {
893
+ this.inflightToken = null;
894
+ });
895
+ }
896
+ return this.inflightToken;
897
+ }
898
+ async fetchToken(key) {
899
+ const { clientId, clientSecret, impersonate } = this.credentials;
900
+ const isImpersonation = !!impersonate;
901
+ const body = {
902
+ grant_type: "client_credentials",
903
+ client_id: clientId,
904
+ client_secret: clientSecret,
905
+ scopes: isImpersonation ? ["ACCESS_DB", "IMPERSONATE", "MANAGE_DB"] : ["ACCESS_DB", "GLOBAL_READ", "GLOBAL_WRITE"]
906
+ // GLOBAL_* needed for /all/ access
907
+ };
908
+ if (isImpersonation) {
909
+ body.claims = impersonate;
910
+ }
911
+ const response = await this.http.fetch(`${this.dbUrl}/token`, {
912
+ method: "POST",
913
+ headers: { "Content-Type": "application/json" },
914
+ body: stringifyBody(body)
915
+ });
916
+ if (!response.ok) {
917
+ const error = await response.text();
918
+ throw new DexieCloudAuthError(
919
+ `Failed to authenticate with client credentials: ${error}`,
920
+ response.status
921
+ );
922
+ }
923
+ const data = await parseResponse(response);
924
+ if (data.type !== "tokens" || !data.accessToken) {
925
+ throw new DexieCloudAuthError(
926
+ `Unexpected token response: ${JSON.stringify(data)}`
927
+ );
928
+ }
929
+ const token = data.accessToken;
930
+ const expiresAt = parseJwtExp(token);
931
+ tokenCache.set(key, { token, expiresAt });
932
+ return token;
933
+ }
934
+ };
935
+
936
+ // src/client.ts
937
+ var DexieCloudClient = class {
938
+ constructor(config) {
939
+ const fullConfig = typeof config === "string" ? { serviceUrl: config } : config;
940
+ if (!fullConfig.serviceUrl) {
941
+ throw new DexieCloudError("serviceUrl is required");
942
+ }
943
+ fullConfig.serviceUrl = fullConfig.serviceUrl.replace(/\/$/, "");
944
+ if (fullConfig.dbUrl) {
945
+ fullConfig.dbUrl = fullConfig.dbUrl.replace(/\/$/, "");
946
+ }
947
+ this.http = createAdapter(fullConfig);
948
+ this.auth = new AuthManager(fullConfig, this.http);
949
+ this.databases = new DatabaseManager(fullConfig, this.http);
950
+ this.health = new HealthManager(fullConfig, this.http);
951
+ const dbUrl = fullConfig.dbUrl ?? fullConfig.serviceUrl;
952
+ this.blobs = new BlobManager(dbUrl, this.http, fullConfig.blobHandling ?? "auto", fullConfig.maxStringLength ?? 32768);
953
+ this.data = new DataManager(dbUrl, this.http, this.blobs);
954
+ }
955
+ /**
956
+ * Convenience method: Full database creation flow with OTP
957
+ * 1. Request OTP → 2. Get OTP from callback → 3. Verify → 4. Create DB
958
+ */
959
+ async createDatabase(email, getOTP, options = {}) {
960
+ const { accessToken } = await this.auth.authenticateWithOTP(email, getOTP, ["CREATE_DB"]);
961
+ const dbInfo = await this.databases.create(accessToken, options);
962
+ return { ...dbInfo, accessToken };
963
+ }
964
+ /** Convenience: Check if service is operational */
965
+ async isReady() {
966
+ return this.health.ready();
967
+ }
968
+ /** Convenience: Get full health status */
969
+ async getStatus() {
970
+ return this.health.status();
971
+ }
972
+ /** Convenience: Wait for service to be ready */
973
+ async waitForReady(timeout) {
974
+ return this.health.waitForReady(timeout);
975
+ }
976
+ /**
977
+ * Create a pre-authenticated DatabaseSession for a specific database.
978
+ *
979
+ * Tokens are acquired lazily and cached in-memory. When a token is
980
+ * within 5 minutes of expiry a fresh one is fetched transparently.
981
+ *
982
+ * @example
983
+ * ```ts
984
+ * const db = client.db('https://xxxxxxxx.dexie.cloud', {
985
+ * clientId: '...',
986
+ * clientSecret: '...',
987
+ * });
988
+ * const items = await db.data.list('todoItems');
989
+ * ```
990
+ */
991
+ db(dbUrl, credentials) {
992
+ const normalizedUrl = dbUrl.replace(/\/$/, "");
993
+ const blobManager = new BlobManager(normalizedUrl, this.http);
994
+ const access = credentials.impersonate ? "my" : "all";
995
+ const dataManager = new DataManager(normalizedUrl, this.http, blobManager, access);
996
+ return new DatabaseSession(normalizedUrl, credentials, this.http, blobManager, dataManager);
997
+ }
998
+ };
999
+ export {
1000
+ AuthManager,
1001
+ BlobManager,
1002
+ DataManager,
1003
+ DatabaseManager,
1004
+ DatabaseSession,
1005
+ DexieCloudAuthError,
1006
+ DexieCloudClient,
1007
+ DexieCloudError,
1008
+ DexieCloudNetworkError,
1009
+ FetchAdapter,
1010
+ HealthManager,
1011
+ NodeAdapter,
1012
+ TSON,
1013
+ createAdapter,
1014
+ DexieCloudClient as default,
1015
+ parse,
1016
+ stringify
1017
+ };