Skip to content

Commit 4195fd4

Browse files
authored
Merge pull request #4 from dexie/liz/blob-handling
fix: devil's advocate SDK fixes + CI workflow
2 parents e256fb1 + cf3d7fc commit 4195fd4

File tree

7 files changed

+228
-83
lines changed

7 files changed

+228
-83
lines changed

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, master, 'liz/**']
6+
pull_request:
7+
branches: [main, master]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [18, 20, 22]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: pnpm/action-setup@v3
18+
with:
19+
version: 9
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: ${{ matrix.node-version }}
23+
- run: pnpm install
24+
- run: pnpm run typecheck
25+
- run: pnpm test -- run

examples/blob-crud/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
# Dexie Cloud SDK Example
1+
# Dexie Cloud SDK — Blob CRUD Example
22

3-
A Node.js example showing how to use the Dexie Cloud SDK for server-side data operations with blob support.
3+
Server-side data operations with blob support using client credentials.
44

55
## What It Shows
66

7-
- Authenticating with OTP
7+
- Authenticating with client credentials (`clientId`/`clientSecret`)
88
- CRUD operations via REST API
99
- Uploading and downloading blobs
10-
- Auto vs lazy blob handling modes
10+
- Auto blob handling mode
1111

1212
## Run
1313

1414
```bash
1515
npm install
16-
# Set your database URL:
16+
17+
# Credentials from your dexie-cloud.key file:
1718
export DEXIE_CLOUD_DB_URL=https://xxxxxxxx.dexie.cloud
18-
export DEXIE_CLOUD_EMAIL=your@email.com
19+
export DEXIE_CLOUD_CLIENT_ID=your-client-id
20+
export DEXIE_CLOUD_CLIENT_SECRET=your-client-secret
21+
1922
npm start
2023
```

examples/blob-crud/src/main.ts

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,45 @@
22
* Dexie Cloud SDK — Blob CRUD Example
33
*
44
* Demonstrates server-side data operations with blob offloading.
5+
* Uses client credentials (clientId/clientSecret from dexie-cloud.key).
56
*/
67

78
import { DexieCloudClient } from 'dexie-cloud-sdk';
89
import * as fs from 'fs';
9-
import * as readline from 'readline/promises';
1010

11+
// Read credentials from dexie-cloud.key or environment
1112
const DB_URL = process.env.DEXIE_CLOUD_DB_URL;
12-
const EMAIL = process.env.DEXIE_CLOUD_EMAIL;
13+
const CLIENT_ID = process.env.DEXIE_CLOUD_CLIENT_ID;
14+
const CLIENT_SECRET = process.env.DEXIE_CLOUD_CLIENT_SECRET;
1315

14-
if (!DB_URL || !EMAIL) {
15-
console.error('Set DEXIE_CLOUD_DB_URL and DEXIE_CLOUD_EMAIL environment variables');
16+
if (!DB_URL || !CLIENT_ID || !CLIENT_SECRET) {
17+
console.error(`Set environment variables:
18+
DEXIE_CLOUD_DB_URL — Your database URL (from dexie-cloud.key)
19+
DEXIE_CLOUD_CLIENT_ID — Client ID (from dexie-cloud.key)
20+
DEXIE_CLOUD_CLIENT_SECRET — Client secret (from dexie-cloud.key)
21+
22+
Or source your dexie-cloud.key file directly.`);
1623
process.exit(1);
1724
}
1825

1926
async function main() {
20-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
21-
2227
// --- Initialize SDK ---
2328

2429
const client = new DexieCloudClient({
25-
serviceUrl: 'https://dexie.cloud',
2630
dbUrl: DB_URL,
31+
clientId: CLIENT_ID,
32+
clientSecret: CLIENT_SECRET,
2733
blobHandling: 'auto' // Binary data handled transparently
2834
});
2935

30-
console.log('🔑 Authenticating...');
31-
32-
// --- Authenticate ---
33-
34-
const { accessToken } = await client.auth.authenticateWithOTP(
35-
EMAIL,
36-
async () => {
37-
const otp = await rl.question('Enter OTP from email: ');
38-
return otp.trim();
39-
},
40-
['ACCESS_DB']
41-
);
42-
36+
console.log('🔑 Authenticating with client credentials...');
37+
const accessToken = await client.auth.getToken(['ACCESS_DB', 'GLOBAL_WRITE']);
4338
console.log('✅ Authenticated!\n');
4439

4540
// --- Create item with binary data ---
4641

4742
console.log('📝 Creating item with binary data...');
4843

49-
// Create a sample image (or read from file)
5044
const imageData = new Uint8Array(1024);
5145
crypto.getRandomValues(imageData); // Random bytes for demo
5246

@@ -92,8 +86,6 @@ async function main() {
9286
console.log('🗑️ Cleaning up...');
9387
await client.data.delete('files', item.id, accessToken);
9488
console.log('✅ Done!\n');
95-
96-
rl.close();
9789
}
9890

9991
main().catch(err => {

src/blob.ts

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,55 @@ import type { HttpAdapter } from './adapters.js';
99
import type { BlobHandling, BlobRef } from './types.js';
1010
import { DexieCloudError } from './types.js';
1111

12+
/**
13+
* Minimum byte size for offloading a binary to blob storage.
14+
* Binaries smaller than this threshold are kept inline (as base64).
15+
* Must match the server-side threshold.
16+
*/
17+
export const BLOB_THRESHOLD = 4096;
18+
19+
/**
20+
* Maximum number of concurrent blob downloads in _walkForRead.
21+
* Mirrors the client-side MAX_CONCURRENT pattern.
22+
*/
23+
const MAX_CONCURRENT_DOWNLOADS = 6;
24+
1225
/** Generate a unique blob ID */
1326
function generateBlobId(): string {
1427
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1528
return crypto.randomUUID().replace(/-/g, '');
1629
}
17-
// Fallback: timestamp + random hex
18-
return Date.now().toString(16) + Math.random().toString(16).slice(2);
30+
// Fallback: use getRandomValues for strong entropy
31+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
32+
const bytes = new Uint8Array(16);
33+
crypto.getRandomValues(bytes);
34+
return Array.from(bytes)
35+
.map((b) => b.toString(16).padStart(2, '0'))
36+
.join('');
37+
}
38+
// Last resort (non-browser, non-Node env): still better than Math.random alone
39+
const ts = Date.now().toString(16);
40+
const rand = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0');
41+
return ts + rand;
1942
}
2043

21-
/** Convert Blob/ArrayBuffer/TypedArray to Uint8Array */
22-
async function toUint8Array(data: Uint8Array | Blob | ArrayBuffer): Promise<Uint8Array> {
44+
/**
45+
* Convert Blob/ArrayBuffer/TypedArray/DataView to Uint8Array.
46+
* Accepts any ArrayBufferView (TypedArrays + DataView) as well as
47+
* Uint8Array, ArrayBuffer, and Blob.
48+
*/
49+
async function toUint8Array(
50+
data: Uint8Array | Blob | ArrayBuffer | ArrayBufferView
51+
): Promise<Uint8Array> {
2352
if (data instanceof Uint8Array) return data;
2453
if (data instanceof ArrayBuffer) return new Uint8Array(data);
2554
if (typeof Blob !== 'undefined' && data instanceof Blob) {
2655
const buf = await data.arrayBuffer();
2756
return new Uint8Array(buf);
2857
}
29-
// TypedArray (e.g. Int8Array, etc.)
58+
// Handles all TypedArrays (Int8Array, Float32Array, etc.) and DataView
3059
if (ArrayBuffer.isView(data)) {
31-
return new Uint8Array((data as ArrayBufferView).buffer);
60+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
3261
}
3362
throw new TypeError('Unsupported data type for blob upload');
3463
}
@@ -92,7 +121,7 @@ export class BlobManager {
92121
* Returns the blob ref (e.g. "1:abc123...").
93122
*/
94123
async upload(
95-
data: Uint8Array | Blob | ArrayBuffer,
124+
data: Uint8Array | Blob | ArrayBuffer | ArrayBufferView,
96125
token: string,
97126
contentType = 'application/octet-stream'
98127
): Promise<string> {
@@ -125,14 +154,20 @@ export class BlobManager {
125154
const parsed = JSON.parse(text);
126155
if (parsed?.ref) return parsed.ref as string;
127156
} catch {
128-
// ignore parse errors, construct ref ourselves
157+
// ignore parse errors, fall through
129158
}
130159
// If server returned "version:blobId" directly
131160
if (text.includes(':')) return text.trim();
132161
}
133162

134-
// Fallback: assume version 1
135-
return `1:${blobId}`;
163+
// Server response was unparseable — we cannot safely construct a ref
164+
// because we don't know the server-assigned version.
165+
throw new DexieCloudError(
166+
`Blob upload succeeded (HTTP ${response.status}) but server returned no parseable ref. ` +
167+
`Cannot construct a safe blob reference without the server-assigned version.`,
168+
response.status,
169+
text
170+
);
136171
}
137172

138173
/**
@@ -160,8 +195,9 @@ export class BlobManager {
160195
}
161196

162197
/**
163-
* Process an object before uploading: find inline blobs, upload them,
164-
* replace with BlobRefs. Only active in 'auto' mode.
198+
* Process an object before uploading: find inline blobs large enough to
199+
* offload (≥ BLOB_THRESHOLD bytes), upload them, replace with BlobRefs.
200+
* Small binaries are left inline. Only active in 'auto' mode.
165201
*/
166202
async processForUpload(obj: any, token: string): Promise<any> {
167203
if (this.mode !== 'auto') return obj;
@@ -179,8 +215,13 @@ export class BlobManager {
179215

180216
private async _walkForUpload(val: any, token: string): Promise<any> {
181217
if (isInlineBlob(val)) {
182-
// Upload inline blob, replace with BlobRef
183218
const bytes = base64ToUint8Array(val.v);
219+
// Only offload to blob storage if the binary meets the size threshold.
220+
// Small binaries are cheaper to keep inline than to round-trip through
221+
// the blob endpoint.
222+
if (bytes.length < BLOB_THRESHOLD) {
223+
return val; // keep as-is
224+
}
184225
const contentType = val.ct ?? 'application/octet-stream';
185226
const ref = await this.upload(bytes, token, contentType);
186227
const blobRef: BlobRef = {
@@ -193,27 +234,21 @@ export class BlobManager {
193234
}
194235

195236
if (Array.isArray(val)) {
196-
const results: any[] = [];
197-
for (const item of val) {
198-
results.push(await this._walkForUpload(item, token));
199-
}
200-
return results;
237+
return Promise.all(val.map((item) => this._walkForUpload(item, token)));
201238
}
202239

203240
if (val !== null && typeof val === 'object') {
204-
const result: Record<string, any> = {};
205-
for (const [k, v] of Object.entries(val)) {
206-
result[k] = await this._walkForUpload(v, token);
207-
}
208-
return result;
241+
const entries = await Promise.all(
242+
Object.entries(val).map(async ([k, v]) => [k, await this._walkForUpload(v, token)] as const)
243+
);
244+
return Object.fromEntries(entries);
209245
}
210246

211247
return val;
212248
}
213249

214250
private async _walkForRead(val: any, token: string): Promise<any> {
215251
if (isBlobRef(val)) {
216-
// Download and replace with inline
217252
const { data, contentType } = await this.download(val.ref, token);
218253
return {
219254
_bt: val._bt,
@@ -223,21 +258,48 @@ export class BlobManager {
223258
}
224259

225260
if (Array.isArray(val)) {
226-
const results: any[] = [];
227-
for (const item of val) {
228-
results.push(await this._walkForRead(item, token));
229-
}
230-
return results;
261+
// Download up to MAX_CONCURRENT_DOWNLOADS blobs in parallel
262+
return this._parallelMap(val, (item) => this._walkForRead(item, token));
231263
}
232264

233265
if (val !== null && typeof val === 'object') {
266+
const keys = Object.keys(val);
267+
const resolvedValues = await this._parallelMap(
268+
keys,
269+
(k) => this._walkForRead(val[k], token)
270+
);
234271
const result: Record<string, any> = {};
235-
for (const [k, v] of Object.entries(val)) {
236-
result[k] = await this._walkForRead(v, token);
272+
for (let i = 0; i < keys.length; i++) {
273+
result[keys[i]!] = resolvedValues[i];
237274
}
238275
return result;
239276
}
240277

241278
return val;
242279
}
280+
281+
/**
282+
* Like Promise.all but with a concurrency cap.
283+
*/
284+
private async _parallelMap<T, R>(
285+
items: T[],
286+
fn: (item: T) => Promise<R>
287+
): Promise<R[]> {
288+
const results: R[] = new Array(items.length);
289+
let index = 0;
290+
291+
async function worker() {
292+
while (index < items.length) {
293+
const i = index++;
294+
results[i] = await fn(items[i]!);
295+
}
296+
}
297+
298+
const workers = Array.from(
299+
{ length: Math.min(MAX_CONCURRENT_DOWNLOADS, items.length) },
300+
() => worker()
301+
);
302+
await Promise.all(workers);
303+
return results;
304+
}
243305
}

src/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ export class DexieCloudClient {
4343

4444
// Use dbUrl if provided, otherwise fall back to serviceUrl
4545
const dbUrl = fullConfig.dbUrl ?? fullConfig.serviceUrl;
46-
this.data = new DataManager(dbUrl, this.http);
4746
this.blobs = new BlobManager(dbUrl, this.http, fullConfig.blobHandling ?? 'auto');
47+
// Pass BlobManager to DataManager so create/get/list auto-process blobs
48+
this.data = new DataManager(dbUrl, this.http, this.blobs);
4849
}
4950

5051
/**

0 commit comments

Comments
 (0)