hydrousdb 3.5.0 → 3.5.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/README.md CHANGED
@@ -10,17 +10,73 @@ npm install hydrousdb
10
10
 
11
11
  ---
12
12
 
13
+ ## Table of Contents
14
+
15
+ - [Setup](#setup)
16
+ - [Records](#records)
17
+ - [Create, Read, Update, Delete](#create-read-update-delete)
18
+ - [Querying & Filtering](#querying--filtering)
19
+ - [Time Scope on Queries](#time-scope-on-queries)
20
+ - [Pagination](#pagination)
21
+ - [Atomic Field Updates](#atomic-field-updates)
22
+ - [Batch Operations](#batch-operations)
23
+ - [Version History](#version-history)
24
+ - [Custom Record IDs](#custom-record-ids)
25
+ - [Existence Check](#existence-check)
26
+ - [Get All Records](#get-all-records)
27
+ - [Auth](#auth)
28
+ - [Sign Up & Log In](#sign-up--log-in)
29
+ - [Session Management](#session-management)
30
+ - [User Profile](#user-profile)
31
+ - [Password & Email](#password--email)
32
+ - [Admin Controls](#admin-controls)
33
+ - [File Storage](#file-storage)
34
+ - [Upload](#upload)
35
+ - [Large Files with Progress](#large-files-with-progress)
36
+ - [Batch Uploads](#batch-uploads)
37
+ - [Download](#download)
38
+ - [Batch Download](#batch-download)
39
+ - [List, Metadata & Signed URLs](#list-metadata--signed-urls)
40
+ - [Move, Copy & Delete](#move-copy--delete)
41
+ - [Visibility](#visibility)
42
+ - [Folders](#folders)
43
+ - [Scoped Storage](#scoped-storage)
44
+ - [Storage Stats](#storage-stats)
45
+ - [Analytics](#analytics)
46
+ - [Date Range (Time Scope)](#date-range-time-scope)
47
+ - [Count](#count)
48
+ - [Distribution](#distribution)
49
+ - [Sum](#sum)
50
+ - [Time Series](#time-series)
51
+ - [Field Time Series](#field-time-series)
52
+ - [Top N](#top-n)
53
+ - [Stats](#stats)
54
+ - [Records via BigQuery](#records-via-bigquery)
55
+ - [Multi-Metric](#multi-metric)
56
+ - [Storage Stats](#storage-stats-1)
57
+ - [Cross-Bucket](#cross-bucket)
58
+ - [Raw Query](#raw-query)
59
+ - [Error Handling](#error-handling)
60
+ - [TypeScript](#typescript)
61
+ - [Security](#security)
62
+ - [Framework Examples](#framework-examples)
63
+ - [API Reference](#api-reference)
64
+
65
+ ---
66
+
13
67
  ## Setup
14
68
 
15
69
  ```ts
16
70
  import { createClient } from 'hydrousdb';
17
71
 
18
72
  const db = createClient({
19
- authKey: process.env.HYDROUS_AUTH_KEY!,
20
- bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
73
+ authKey: process.env.HYDROUS_AUTH_KEY!, // hk_auth_…
74
+ bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!, // hk_bucket_…
21
75
  storageKeys: {
22
- main: process.env.HYDROUS_STORAGE_KEY!,
76
+ main: process.env.HYDROUS_STORAGE_KEY!, // ssk_…
77
+ // add more named storage keys as needed
23
78
  },
79
+ // baseUrl: 'https://custom-endpoint.example.com', // optional override
24
80
  });
25
81
  ```
26
82
 
@@ -32,104 +88,225 @@ Works in **React, Next.js, Vue, React Native, Node.js** — anywhere that runs m
32
88
 
33
89
  ## Records
34
90
 
35
- JSON objects stored in named buckets. Every record gets `id`, `createdAt`, and `updatedAt` automatically.
91
+ JSON objects stored in named buckets. Every record automatically gets `id`, `createdAt`, and `updatedAt`.
36
92
 
37
93
  ```ts
38
94
  const posts = db.records('blog-posts');
95
+ ```
96
+
97
+ ### Create, Read, Update, Delete
39
98
 
99
+ ```ts
40
100
  // Create
41
101
  const post = await posts.create(
42
102
  { title: 'Hello', status: 'draft', views: 0 },
43
- { queryableFields: ['status'] }, // declare fields you want to filter on
103
+ {
104
+ queryableFields: ['status', 'views'], // declare fields you want to filter on
105
+ userEmail: 'alice@example.com', // optional — for audit trails
106
+ },
44
107
  );
45
- console.log(post.id); // "260601-rec_01JA2XYZ"
108
+ console.log(post.id); // "260601-rec_01JA2XYZ"
109
+ console.log(post.createdAt); // Unix ms timestamp
46
110
 
47
- // Read (path computed from ID — zero index lookups)
111
+ // Read by ID
48
112
  const found = await posts.get(post.id);
49
113
 
50
- // Update (only the fields you pass are changed)
114
+ // Partial update — only the fields you pass are changed
51
115
  await posts.patch(post.id, { status: 'published' });
52
116
 
53
- // Delete
117
+ // Merge mode — deeply merge nested objects instead of replacing them
118
+ await posts.patch(post.id, { meta: { seo: true } }, { merge: true });
119
+
120
+ // Delete permanently
54
121
  await posts.delete(post.id);
55
122
  ```
56
123
 
57
- > **Why `queryableFields`?** Records are stored as compressed blobs. Fields you want to filter or sort by must be registered at write time. You only pay overhead for what you actually query.
124
+ > **Why `queryableFields`?** Records are stored as compressed blobs. Fields you want to filter or sort by must be declared at write time. You only pay the indexing overhead for what you actually query.
58
125
 
59
- ### Query
126
+ ---
127
+
128
+ ### Querying & Filtering
60
129
 
61
130
  ```ts
62
131
  const { records, hasMore, nextCursor } = await posts.query({
63
132
  filters: [
64
- { field: 'status', op: '==', value: 'published' },
65
- { field: 'views', op: '>', value: 100 },
133
+ { field: 'status', op: '==', value: 'published' },
134
+ { field: 'views', op: '>', value: 100 },
135
+ { field: 'title', op: 'contains', value: 'hello' },
66
136
  ],
67
137
  orderBy: 'createdAt',
68
138
  order: 'desc',
69
139
  limit: 20,
140
+ fields: 'id,title,status', // optional — return only these fields
141
+ });
142
+ ```
143
+
144
+ **Supported filter operators:** `==` `!=` `>` `<` `>=` `<=` `contains`
145
+
146
+ ---
147
+
148
+ ### Time Scope on Queries
149
+
150
+ Pass `timeScope` to restrict records to a specific **day, month, or year** using the record ID prefix convention. This is the fastest way to scope a query by time — no timestamp arithmetic needed.
151
+
152
+ | Scope | Format | Example | Matches |
153
+ |---|---|---|---|
154
+ | Day | `_day_YYMMDD` | `_day_260305` | March 5, 2026 |
155
+ | Month | `_month_YYMM` | `_month_2603` | March 2026 |
156
+ | Year | `_year_YY` | `_year_26` | All of 2026 |
157
+
158
+ ```ts
159
+ // All records from March 2026
160
+ const { records, hasMore, nextCursor } = await posts.query({
161
+ timeScope: '_month_2603',
162
+ order: 'desc',
163
+ limit: 50,
70
164
  });
71
165
 
72
- // Next page
166
+ // Paginate through a time-scoped result set
73
167
  if (hasMore) {
74
- const page2 = await posts.query({ limit: 20, startAfter: nextCursor });
168
+ const page2 = await posts.query({
169
+ timeScope: '_month_2603',
170
+ startAfter: nextCursor,
171
+ });
75
172
  }
173
+
174
+ // A specific day
175
+ const { records: dayRecords } = await posts.query({
176
+ timeScope: '_day_260305', // March 5, 2026
177
+ order: 'asc',
178
+ });
179
+
180
+ // An entire year
181
+ const { records: yearRecords } = await posts.query({
182
+ timeScope: '_year_26',
183
+ orderBy: 'createdAt',
184
+ order: 'asc',
185
+ limit: 100,
186
+ });
187
+
188
+ // Fully composable with filters
189
+ const { records: published } = await posts.query({
190
+ timeScope: '_month_2603',
191
+ filters: [{ field: 'status', op: '==', value: 'published' }],
192
+ orderBy: 'createdAt',
193
+ order: 'desc',
194
+ });
195
+
196
+ // getAll() also respects timeScope
197
+ const all = await posts.getAll({ timeScope: '_year_26' });
76
198
  ```
77
199
 
78
- Filter operators: `==` `!=` `>` `<` `>=` `<=` `CONTAINS`
200
+ ---
79
201
 
80
- ### Atomic field updates
202
+ ### Pagination
81
203
 
82
- Avoid race conditions with server-side operations inside `patch()`:
204
+ `query()` returns a cursor you can pass straight into the next call.
205
+
206
+ ```ts
207
+ // Page 1
208
+ const page1 = await posts.query({ limit: 20, orderBy: 'createdAt', order: 'desc' });
209
+
210
+ // Page 2
211
+ if (page1.hasMore) {
212
+ const page2 = await posts.query({
213
+ limit: 20,
214
+ orderBy: 'createdAt',
215
+ order: 'desc',
216
+ startAfter: page1.nextCursor, // cursor-based — no offset drift
217
+ });
218
+ }
219
+
220
+ // You can also use startAt / endAt for range-based cursor control
221
+ const window = await posts.query({ startAt: cursorA, endAt: cursorB });
222
+ ```
223
+
224
+ ---
225
+
226
+ ### Atomic Field Updates
227
+
228
+ Avoid race conditions with server-side sentinels inside `patch()`:
83
229
 
84
230
  ```ts
85
231
  await posts.patch(post.id, {
86
- views: { __op: 'increment', delta: 1 }, // add 1
87
- credits: { __op: 'decrement', delta: 5 }, // subtract 5
88
- slug: { __op: 'setOnce', value: 'my-post' }, // set only if empty
89
- tags: { __op: 'appendUnique', item: 'featured' }, // add to array, no dupes
90
- tags: { __op: 'removeFromArray', item: 'draft' }, // remove from array
91
- rating: { __op: 'clamp', value: 6, min: 0, max: 5 },
92
- price: { __op: 'multiplyBy', factor: 1.1 },
93
- active: { __op: 'toggleBool' },
94
- syncedAt: { __op: 'serverTimestamp' },
232
+ views: { __op: 'increment', delta: 1 }, // add N
233
+ credits: { __op: 'decrement', delta: 5 }, // subtract N
234
+ slug: { __op: 'setOnce', value: 'my-post' }, // set only if currently empty
235
+ tags: { __op: 'appendUnique', item: 'featured' }, // add to array, no duplicates
236
+ tags: { __op: 'removeFromArray', item: 'draft' }, // remove from array
237
+ rating: { __op: 'clamp', value: 6, min: 0, max: 5 }, // clamp to range
238
+ price: { __op: 'multiplyBy', factor: 1.1 }, // multiply
239
+ active: { __op: 'toggleBool' }, // flip boolean
240
+ syncedAt: { __op: 'serverTimestamp' }, // set to server time
95
241
  } as any);
96
242
  ```
97
243
 
98
- ### Batch operations
244
+ Enable audit trails and history with extra `patch` options:
245
+
246
+ ```ts
247
+ await posts.patch(
248
+ post.id,
249
+ { status: 'published' },
250
+ { userEmail: 'alice@example.com', trackHistory: true },
251
+ );
252
+ ```
253
+
254
+ ---
255
+
256
+ ### Batch Operations
99
257
 
100
258
  ```ts
101
259
  // Create up to 500 records at once
102
- const { results, errors } = await posts.batchCreate(
103
- [{ title: 'A' }, { title: 'B' }],
104
- { queryableFields: ['title'] },
260
+ const { results, errors, successful, failed } = await posts.batchCreate(
261
+ [{ title: 'A', status: 'draft' }, { title: 'B', status: 'draft' }],
262
+ { queryableFields: ['title', 'status'], userEmail: 'alice@example.com' },
105
263
  );
106
264
 
107
265
  // Update up to 500 records at once
108
- await posts.batchUpdate([
109
- { recordId: 'id1', values: { status: 'archived' } },
110
- { recordId: 'id2', values: { status: 'archived' } },
111
- ]);
266
+ const { successful, failed } = await posts.batchUpdate(
267
+ [
268
+ { recordId: 'id1', values: { status: 'archived' } },
269
+ { recordId: 'id2', values: { status: 'archived' } },
270
+ ],
271
+ 'alice@example.com', // optional userEmail
272
+ );
112
273
 
113
274
  // Delete up to 500 records at once
114
275
  const { successful, failed } = await posts.batchDelete(['id1', 'id2']);
115
276
  ```
116
277
 
117
- ### Version history
278
+ Batch upsert using custom IDs — include `_customRecordId` on each item:
279
+
280
+ ```ts
281
+ await posts.batchCreate([
282
+ { _customRecordId: '260601-post_hello', title: 'Hello' },
283
+ { _customRecordId: '260601-post_world', title: 'World' },
284
+ ] as any);
285
+ ```
286
+
287
+ ---
288
+
289
+ ### Version History
118
290
 
119
- Every write is automatically versioned.
291
+ Every write is automatically versioned when `trackHistory` is enabled.
120
292
 
121
293
  ```ts
294
+ // List all saved versions
122
295
  const history = await posts.getHistory(post.id);
123
- // [{ generation, savedAt, savedBy, sizeBytes }, …]
296
+ // [{ generation, savedAt, savedBy, sizeBytes }, …]
124
297
 
125
- // Read a specific past version
298
+ // Retrieve a specific past version
126
299
  const v1 = await posts.getVersion(post.id, history[0].generation!);
127
300
  ```
128
301
 
129
- ### Custom record IDs
302
+ ---
303
+
304
+ ### Custom Record IDs
305
+
306
+ Supply your own ID at creation time — if it already exists the record is upserted in-place.
130
307
 
131
308
  ```ts
132
- // If the ID already exists, the record is upserted
309
+ // Format: YYMMDD-segment1__segment2
133
310
  const post = await posts.create(
134
311
  { title: 'Welcome' },
135
312
  { customRecordId: '260601-post_welcome' },
@@ -138,6 +315,30 @@ const post = await posts.create(
138
315
 
139
316
  ---
140
317
 
318
+ ### Existence Check
319
+
320
+ A lightweight HEAD request — much cheaper than fetching the full record:
321
+
322
+ ```ts
323
+ const exists = await posts.exists(post.id); // true | false
324
+ ```
325
+
326
+ ---
327
+
328
+ ### Get All Records
329
+
330
+ Fetches every record matching the options without filter support. Use `query()` when you need filters.
331
+
332
+ ```ts
333
+ const all = await posts.getAll({
334
+ orderBy: 'createdAt',
335
+ order: 'desc',
336
+ limit: 500,
337
+ });
338
+ ```
339
+
340
+ ---
341
+
141
342
  ## Auth
142
343
 
143
344
  A complete user system — signup, login, sessions, password reset, email verification, and admin controls.
@@ -146,7 +347,7 @@ A complete user system — signup, login, sessions, password reset, email verifi
146
347
  const auth = db.auth();
147
348
  ```
148
349
 
149
- ### Sign up & log in
350
+ ### Sign Up & Log In
150
351
 
151
352
  ```ts
152
353
  // Sign up — extra fields beyond email/password are stored on the user
@@ -163,40 +364,75 @@ const { user, session } = await auth.login({
163
364
  password: 'hunter2',
164
365
  });
165
366
 
166
- // Log out (pass allDevices: true to log out everywhere)
367
+ // Log out this device
167
368
  await auth.logout({ sessionId: session.sessionId });
369
+
370
+ // Log out everywhere
371
+ await auth.logout({ sessionId: session.sessionId, allDevices: true });
168
372
  ```
169
373
 
170
- Store `session.sessionId` and `session.refreshToken` in your app. Sessions expire after **24 hours**, refresh tokens after **30 days**.
374
+ Store `session.sessionId` and `session.refreshToken` in your app.
375
+
376
+ | Token | Lifetime |
377
+ |---|---|
378
+ | `sessionId` | 24 hours |
379
+ | `refreshToken` | 30 days |
380
+
381
+ ---
171
382
 
172
- ### Session management
383
+ ### Session Management
173
384
 
174
385
  ```ts
175
- // Check if a session is still valid (use on your backend)
176
- const { user } = await auth.validateSession(session.sessionId);
386
+ // Validate a session and get the current user (use on your backend)
387
+ const { user, session } = await auth.validateSession(session.sessionId);
388
+ // session → { sessionId, expiresAt }
177
389
 
178
- // Get a new session using the refresh token
390
+ // Get a brand-new session from a refresh token (before the old one expires)
179
391
  const newSession = await auth.refreshSession(session.refreshToken);
180
392
  ```
181
393
 
182
- ### User profile
394
+ ---
395
+
396
+ ### User Profile
183
397
 
184
398
  ```ts
399
+ // Fetch a user by ID
185
400
  const user = await auth.getUser(session.userId);
186
401
 
402
+ // Update profile fields (users can update themselves; admins can update anyone)
187
403
  await auth.updateUser({
188
404
  sessionId: session.sessionId,
189
405
  userId: user.id,
190
406
  updates: { fullName: 'Alice Johnson', plan: 'enterprise' },
191
407
  });
192
408
 
409
+ // Soft-delete (users can delete themselves; admins can delete anyone)
193
410
  await auth.deleteUser(session.sessionId, user.id);
194
411
  ```
195
412
 
196
- ### Password & email
413
+ The `UserRecord` shape:
414
+
415
+ ```ts
416
+ interface UserRecord {
417
+ id: string;
418
+ email: string;
419
+ fullName?: string | null;
420
+ emailVerified: boolean;
421
+ accountStatus: 'active' | 'locked' | 'suspended';
422
+ role: 'user' | 'admin';
423
+ createdAt: number; // Unix ms
424
+ updatedAt: number; // Unix ms
425
+ metadata?: Record<string, unknown>;
426
+ [key: string]: unknown; // custom fields from signup
427
+ }
428
+ ```
429
+
430
+ ---
431
+
432
+ ### Password & Email
197
433
 
198
434
  ```ts
199
- // Change password (requires active session)
435
+ // Change password requires an active session AND the current password
200
436
  await auth.changePassword({
201
437
  sessionId: session.sessionId,
202
438
  userId: user.id,
@@ -204,8 +440,8 @@ await auth.changePassword({
204
440
  newPassword: 'correcthorsebatterystaple',
205
441
  });
206
442
 
207
- // Forgot password flow
208
- await auth.requestPasswordReset('alice@example.com');
443
+ // Forgot-password flow
444
+ await auth.requestPasswordReset('alice@example.com'); // always succeeds (prevents enumeration)
209
445
  await auth.confirmPasswordReset(tokenFromEmail, 'newpassword123');
210
446
 
211
447
  // Email verification
@@ -213,25 +449,38 @@ await auth.requestEmailVerification(user.id);
213
449
  await auth.confirmEmailVerification(tokenFromEmail);
214
450
  ```
215
451
 
216
- ### Admin
452
+ ---
453
+
454
+ ### Admin Controls
455
+
456
+ All admin methods require an active admin session.
217
457
 
218
458
  ```ts
219
- // List all users (paginated)
459
+ // Paginated user list
220
460
  const { users, hasMore, nextCursor } = await auth.listUsers({
221
461
  sessionId: adminSession.sessionId,
222
462
  limit: 50,
463
+ cursor: previousNextCursor, // optional — for subsequent pages
223
464
  });
224
465
 
225
- // Lock / unlock an account
226
- await auth.lockAccount({ sessionId: adminSession.sessionId, userId: user.id });
466
+ // Lock an account (default: 15 minutes)
467
+ const { lockedUntil, unlockTime } = await auth.lockAccount({
468
+ sessionId: adminSession.sessionId,
469
+ userId: user.id,
470
+ duration: 60 * 60 * 1000, // optional ms — lock for 1 hour
471
+ });
472
+
473
+ // Unlock an account
227
474
  await auth.unlockAccount(adminSession.sessionId, user.id);
228
475
 
229
- // Delete users
476
+ // Permanent (hard) delete — cannot be undone
230
477
  await auth.hardDeleteUser(adminSession.sessionId, user.id);
231
- await auth.bulkDeleteUsers({
478
+
479
+ // Bulk delete — soft or hard
480
+ const { succeeded, failed } = await auth.bulkDeleteUsers({
232
481
  sessionId: adminSession.sessionId,
233
- userIds: ['id1', 'id2'],
234
- hard: true,
482
+ userIds: ['id1', 'id2', 'id3'],
483
+ hard: true, // optional — defaults to soft delete
235
484
  });
236
485
  ```
237
486
 
@@ -239,79 +488,227 @@ await auth.bulkDeleteUsers({
239
488
 
240
489
  ## File Storage
241
490
 
491
+ Files are **private by default** and scoped to your storage key server-side.
492
+
242
493
  ```ts
243
- const storage = db.storage('main');
494
+ const storage = db.storage('main'); // 'main' matches a key in storageKeys config
244
495
  ```
245
496
 
246
497
  ### Upload
247
498
 
248
499
  ```ts
249
- // Simple upload (up to 500 MB)
250
- const result = await storage.upload(file, 'avatars/alice.jpg', { isPublic: true });
251
- console.log(result.publicUrl); // permanent CDN URL
252
- console.log(result.downloadUrl); // authenticated download URL (if private)
253
-
254
- // Upload a JSON object or string directly
255
- await storage.uploadRaw({ theme: 'dark' }, 'config.json');
500
+ // Simple upload — anything up to 500 MB
501
+ const result = await storage.upload(file, 'avatars/alice.jpg', {
502
+ isPublic: true, // public CDN URL (default: false)
503
+ overwrite: true, // replace if the path exists (default: false)
504
+ mimeType: 'image/jpeg', // optional — auto-detected from content if omitted
505
+ expiresInSeconds: 3600, // optional auto-delete after N seconds
506
+ });
507
+ console.log(result.publicUrl); // permanent CDN URL (if isPublic: true)
508
+ console.log(result.downloadUrl); // authenticated URL (if private)
509
+ console.log(result.path);
510
+ console.log(result.size);
511
+ console.log(result.mimeType);
512
+
513
+ // Upload a JS object or string directly as a file
514
+ await storage.uploadRaw({ theme: 'dark', lang: 'en' }, 'settings/config.json');
515
+ await storage.uploadRaw('<html>…</html>', 'pages/home.html', { mimeType: 'text/html' });
256
516
  ```
257
517
 
258
- **Large files with progress tracking** (recommended for anything > 10 MB):
518
+ ---
519
+
520
+ ### Large Files with Progress
521
+
522
+ Recommended for files > 10 MB or when you need a progress indicator (browsers only).
259
523
 
260
524
  ```ts
261
- // Step 1 — get a signed upload URL
262
- const { uploadUrl, path } = await storage.getUploadUrl({
263
- path: 'videos/intro.mp4',
525
+ // Step 1 — get a signed GCS PUT URL
526
+ const { uploadUrl, path, expiresAt, expiresIn } = await storage.getUploadUrl({
527
+ path: 'videos/intro.mp4',
528
+ mimeType: 'video/mp4',
529
+ size: file.size,
530
+ isPublic: true,
531
+ overwrite: false,
532
+ expiresInSeconds: 3600, // URL lifetime
533
+ });
534
+
535
+ // Step 2 — upload directly to GCS with progress callback (browser XHR)
536
+ await storage.uploadToSignedUrl(uploadUrl, file, 'video/mp4', (percent) => {
537
+ console.log(`${percent}% uploaded`);
538
+ });
539
+
540
+ // Step 3 — confirm and register metadata server-side
541
+ const result = await storage.confirmUpload({
542
+ path,
264
543
  mimeType: 'video/mp4',
265
- size: file.size,
266
544
  isPublic: true,
267
545
  });
546
+ ```
268
547
 
269
- // Step 2 — upload directly to GCS (supports onProgress in browsers)
270
- await storage.uploadToSignedUrl(uploadUrl, file, 'video/mp4', pct => {
271
- console.log(`${pct}% uploaded`);
272
- });
548
+ ---
549
+
550
+ ### Batch Uploads
551
+
552
+ Get signed URLs for up to 50 files at once, upload them in parallel, then confirm in one call.
553
+
554
+ ```ts
555
+ // Step 1 — get signed URLs for multiple files
556
+ const { files: urls } = await storage.getBatchUploadUrls([
557
+ { path: 'docs/a.pdf', mimeType: 'application/pdf', size: fileA.size, isPublic: false },
558
+ { path: 'docs/b.pdf', mimeType: 'application/pdf', size: fileB.size, isPublic: false },
559
+ ]);
560
+
561
+ // Step 2 — upload each file (run in parallel)
562
+ await Promise.all(
563
+ urls.map(({ uploadUrl, path }, i) =>
564
+ storage.uploadToSignedUrl(uploadUrl, files[i], 'application/pdf'),
565
+ ),
566
+ );
273
567
 
274
- // Step 3 — confirm and get the final result
275
- const result = await storage.confirmUpload({ path, mimeType: 'video/mp4', isPublic: true });
568
+ // Step 3 — confirm all at once
569
+ const { succeeded, failed } = await storage.batchConfirmUploads([
570
+ { path: 'docs/a.pdf', mimeType: 'application/pdf' },
571
+ { path: 'docs/b.pdf', mimeType: 'application/pdf' },
572
+ ]);
573
+ ```
574
+
575
+ ---
576
+
577
+ ### Download
578
+
579
+ ```ts
580
+ // Download a private file as ArrayBuffer
581
+ const buffer = await storage.download('private/report.pdf');
582
+
583
+ // Convert to a Blob for use in the browser
584
+ const blob = new Blob([buffer], { type: 'application/pdf' });
585
+ const url = URL.createObjectURL(blob);
276
586
  ```
277
587
 
278
- ### Download, list, manage
588
+ For **public** files, just use the `publicUrl` directly — no SDK or authentication needed.
589
+
590
+ ---
591
+
592
+ ### Batch Download
593
+
594
+ Downloads up to 20 files at once. Content is returned as base64-encoded strings.
279
595
 
280
596
  ```ts
281
- // Download a private file
282
- const buffer = await storage.download('private/doc.pdf');
597
+ const { succeeded, failed } = await storage.batchDownload([
598
+ 'docs/report.pdf',
599
+ 'images/chart.png',
600
+ ]);
601
+
602
+ for (const file of succeeded) {
603
+ console.log(file.path, file.mimeType, file.size);
604
+ // file.content is base64 — decode with atob() or Buffer.from(content, 'base64')
605
+ }
606
+
607
+ for (const err of failed) {
608
+ console.error(err.path, err.error, err.code);
609
+ }
610
+ ```
611
+
612
+ ---
283
613
 
284
- // List files
285
- const { files, folders, hasMore, nextCursor } = await storage.list({ prefix: 'avatars/' });
614
+ ### List, Metadata & Signed URLs
615
+
616
+ ```ts
617
+ // List files and folders (paginated)
618
+ const { files, folders, hasMore, nextCursor } = await storage.list({
619
+ prefix: 'avatars/', // optional path prefix to list under
620
+ limit: 50,
621
+ cursor: previousNextCursor,
622
+ recursive: true, // include files in sub-folders (default: false)
623
+ });
624
+
625
+ // Page 2
626
+ if (hasMore) {
627
+ const page2 = await storage.list({ prefix: 'avatars/', cursor: nextCursor });
628
+ }
286
629
 
287
630
  // Get file metadata
288
631
  const meta = await storage.getMetadata('avatars/alice.jpg');
632
+ // → { path, size, mimeType, isPublic, publicUrl, downloadUrl, createdAt, updatedAt }
289
633
 
290
- // Time-limited share link (no login needed to access)
291
- const { signedUrl } = await storage.getSignedUrl('private/report.pdf', 3600);
634
+ // Generate a time-limited link — anyone with the URL can download (no key needed)
635
+ // Note: downloads via signed URL bypass the server, so download stats are NOT tracked
636
+ const { signedUrl, expiresAt, expiresIn } = await storage.getSignedUrl(
637
+ 'private/report.pdf',
638
+ 3600, // lifetime in seconds (default: 3600)
639
+ );
640
+ ```
292
641
 
293
- // Move, copy, delete
642
+ ---
643
+
644
+ ### Move, Copy & Delete
645
+
646
+ ```ts
294
647
  await storage.move('old/path.jpg', 'new/path.jpg');
295
- await storage.copy('template.html', 'pages/home.html');
648
+ await storage.copy('templates/base.html', 'pages/home.html');
296
649
  await storage.deleteFile('avatars/old.jpg');
297
- await storage.deleteFolder('temp/');
650
+ await storage.deleteFolder('temp/'); // recursively deletes all contents
651
+ ```
652
+
653
+ ---
654
+
655
+ ### Visibility
656
+
657
+ ```ts
658
+ // Make a private file public
659
+ const result = await storage.setVisibility('reports/q1.pdf', true);
660
+ console.log(result.publicUrl);
298
661
 
299
- // Change visibility after upload
300
- await storage.setVisibility('alice.jpg', false); // make private
662
+ // Make a public file private
663
+ await storage.setVisibility('reports/q1.pdf', false);
301
664
  ```
302
665
 
303
- ### Scoped storage
666
+ ---
667
+
668
+ ### Folders
669
+
670
+ ```ts
671
+ // Create an explicit folder marker (usually not needed — folders are implicit)
672
+ await storage.createFolder('projects/2025/');
673
+ ```
304
674
 
305
- Automatically prefix every path — useful for per-user file isolation:
675
+ ---
676
+
677
+ ### Scoped Storage
678
+
679
+ Automatically prefix every path — ideal for per-user file isolation.
306
680
 
307
681
  ```ts
308
682
  const userFiles = db.storage('main').scope(`users/${userId}/`);
309
683
 
310
684
  await userFiles.upload(pdf, 'contract.pdf'); // → users/{userId}/contract.pdf
311
- const { files } = await userFiles.list(); // → only lists users/{userId}/
685
+ await userFiles.uploadRaw({ key: 'val' }, 'prefs.json');
686
+ const { files } = await userFiles.list(); // → only lists users/{userId}/
312
687
 
313
688
  // Nest deeper
314
- const reports = userFiles.scope('reports/'); // → users/{userId}/reports/
689
+ const reports = userFiles.scope('reports/'); // → users/{userId}/reports/
690
+ await reports.upload(file, 'q1.pdf'); // → users/{userId}/reports/q1.pdf
691
+
692
+ // All StorageManager methods are available on ScopedStorage
693
+ const meta = await userFiles.getMetadata('contract.pdf');
694
+ const { signedUrl } = await userFiles.getSignedUrl('contract.pdf', 900);
695
+ await userFiles.move('old.pdf', 'new.pdf');
696
+ await userFiles.deleteFile('contract.pdf');
697
+ await userFiles.deleteFolder('reports/');
698
+ ```
699
+
700
+ ---
701
+
702
+ ### Storage Stats
703
+
704
+ ```ts
705
+ // Stats for this storage key
706
+ const stats = await storage.getStats();
707
+ // → { totalFiles, totalBytes, uploadCount, downloadCount, deleteCount }
708
+
709
+ // Server info (no auth required)
710
+ const info = await storage.info();
711
+ // → { ok: true, storageRoot: '…' }
315
712
  ```
316
713
 
317
714
  ---
@@ -322,48 +719,267 @@ BigQuery-powered aggregations. No ETL, no pipelines — just query.
322
719
 
323
720
  ```ts
324
721
  const analytics = db.analytics('orders');
722
+ ```
723
+
724
+ ### Date Range (Time Scope)
725
+
726
+ Almost every analytics method accepts an optional `dateRange` to restrict results to a time window. Both `start` and `end` are Unix timestamps **in milliseconds** — the server converts them to ISO strings internally. Both fields are optional; omit either for an open-ended range.
727
+
728
+ ```ts
729
+ interface DateRange {
730
+ start?: number; // Unix ms — inclusive lower bound
731
+ end?: number; // Unix ms — inclusive upper bound
732
+ }
733
+ ```
734
+
735
+ **Granularity options** (for time series methods):
736
+
737
+ | Value | Buckets results by |
738
+ |---|---|
739
+ | `'hour'` | Each hour |
740
+ | `'day'` | Each calendar day |
741
+ | `'week'` | Each week |
742
+ | `'month'` | Each month |
743
+ | `'year'` | Each year |
744
+
745
+ **Aggregation options** (for numeric field methods):
746
+
747
+ | Value | Meaning |
748
+ |---|---|
749
+ | `'sum'` | Total |
750
+ | `'avg'` | Average |
751
+ | `'min'` | Minimum |
752
+ | `'max'` | Maximum |
753
+ | `'count'` | Record count |
754
+
755
+ ---
325
756
 
326
- // Total record count
757
+ ### Count
758
+
759
+ ```ts
760
+ // All-time total
327
761
  const { count } = await analytics.count();
328
762
 
329
- // How many records have each value of `status`
330
- const dist = await analytics.distribution({ field: 'status' });
763
+ // Within a time window
764
+ const { count: lastWeek } = await analytics.count({
765
+ dateRange: {
766
+ start: Date.now() - 7 * 24 * 60 * 60 * 1000,
767
+ end: Date.now(),
768
+ },
769
+ });
770
+ ```
331
771
 
332
- // Sum of `amount`, grouped by `region`
333
- const sums = await analytics.sum({ field: 'amount', groupBy: 'region' });
772
+ ---
334
773
 
335
- // Records over time
336
- const daily = await analytics.timeSeries({ granularity: 'day' });
774
+ ### Distribution
337
775
 
338
- // How `revenue` changes over time
339
- const trend = await analytics.fieldTimeSeries({ field: 'revenue', aggregation: 'sum' });
776
+ How many records have each value of a field.
340
777
 
341
- // Top 10 countries by record count
342
- const top10 = await analytics.topN({ field: 'country', n: 10 });
778
+ ```ts
779
+ const dist = await analytics.distribution({
780
+ field: 'status',
781
+ limit: 10,
782
+ order: 'desc', // 'asc' | 'desc'
783
+ dateRange: { start: new Date('2025-01-01').getTime() },
784
+ });
785
+ // → [{ value: 'published', count: 320 }, { value: 'draft', count: 80 }, …]
786
+ ```
343
787
 
344
- // Min / max / avg / sum / stddev for a numeric field
345
- const stats = await analytics.stats({ field: 'price' });
788
+ ---
789
+
790
+ ### Sum
791
+
792
+ Sum a numeric field, optionally grouped by another field.
793
+
794
+ ```ts
795
+ // Total revenue
796
+ const [{ sum: totalRevenue }] = await analytics.sum({ field: 'amount' });
797
+
798
+ // Revenue by region, last quarter
799
+ const sums = await analytics.sum({
800
+ field: 'amount',
801
+ groupBy: 'region',
802
+ limit: 20,
803
+ dateRange: {
804
+ start: new Date('2025-01-01').getTime(),
805
+ end: new Date('2025-03-31').getTime(),
806
+ },
807
+ });
808
+ // → [{ group: 'Europe', sum: 18200 }, { group: 'Americas', sum: 29400 }, …]
809
+ ```
810
+
811
+ ---
812
+
813
+ ### Time Series
814
+
815
+ Count of records over time — useful for activity graphs.
816
+
817
+ ```ts
818
+ // Daily record counts, last 30 days
819
+ const daily = await analytics.timeSeries({
820
+ granularity: 'day',
821
+ dateRange: {
822
+ start: Date.now() - 30 * 24 * 60 * 60 * 1000,
823
+ end: Date.now(),
824
+ },
825
+ });
826
+ // → [{ date: '2025-03-01', count: 42 }, { date: '2025-03-02', count: 55 }, …]
827
+
828
+ // Monthly, all time
829
+ const monthly = await analytics.timeSeries({ granularity: 'month' });
830
+ ```
831
+
832
+ ---
833
+
834
+ ### Field Time Series
835
+
836
+ Aggregate a numeric field over time — useful for revenue, score, or usage trends.
837
+
838
+ ```ts
839
+ // Daily sum of revenue, last 90 days
840
+ const revTrend = await analytics.fieldTimeSeries({
841
+ field: 'amount',
842
+ aggregation: 'sum',
843
+ granularity: 'day',
844
+ dateRange: {
845
+ start: Date.now() - 90 * 24 * 60 * 60 * 1000,
846
+ end: Date.now(),
847
+ },
848
+ });
849
+ // → [{ date: '2025-01-01', value: 4820.5 }, …]
850
+
851
+ // Monthly average order value
852
+ const avgTrend = await analytics.fieldTimeSeries({
853
+ field: 'amount',
854
+ aggregation: 'avg',
855
+ granularity: 'month',
856
+ });
857
+ ```
858
+
859
+ ---
860
+
861
+ ### Top N
862
+
863
+ Most frequent values for a field by record count.
864
+
865
+ ```ts
866
+ // Top 10 countries
867
+ const top10 = await analytics.topN({
868
+ field: 'countryCode',
869
+ n: 10,
870
+ labelField: 'countryName', // optional — include a human-readable label alongside the value
871
+ order: 'desc',
872
+ dateRange: { start: new Date('2025-01-01').getTime() },
873
+ });
874
+ // → [{ value: 'US', label: 'United States', count: 420 }, …]
875
+ ```
346
876
 
347
- // Multiple aggregations in one round-trip
877
+ ---
878
+
879
+ ### Stats
880
+
881
+ Statistical summary for a numeric field.
882
+
883
+ ```ts
884
+ const priceStats = await analytics.stats({
885
+ field: 'price',
886
+ dateRange: { start: new Date('2025-01-01').getTime() },
887
+ });
888
+ // → { min, max, avg, sum, count, stddev }
889
+ ```
890
+
891
+ ---
892
+
893
+ ### Records via BigQuery
894
+
895
+ Fetch filtered records through the BigQuery engine instead of Firestore. Useful for large result sets or complex server-side filtering.
896
+
897
+ ```ts
898
+ const records = await analytics.records<Order>({
899
+ filters: [
900
+ { field: 'status', op: '==', value: 'paid' },
901
+ { field: 'amount', op: '>=', value: 100 },
902
+ { field: 'country', op: 'CONTAINS', value: 'US' },
903
+ ],
904
+ selectFields: ['id', 'amount', 'country', 'createdAt'], // optional projection
905
+ orderBy: 'createdAt',
906
+ order: 'desc',
907
+ limit: 1000,
908
+ offset: 0,
909
+ dateRange: {
910
+ start: new Date('2025-01-01').getTime(),
911
+ end: new Date('2025-03-31').getTime(),
912
+ },
913
+ });
914
+ ```
915
+
916
+ Analytics filter operators: `==` `!=` `>` `<` `>=` `<=` `CONTAINS`
917
+
918
+ ---
919
+
920
+ ### Multi-Metric
921
+
922
+ Compute multiple aggregations in a single round-trip — perfect for dashboards.
923
+
924
+ ```ts
348
925
  const dashboard = await analytics.multiMetric({
349
926
  metrics: [
350
927
  { field: 'amount', name: 'totalRevenue', aggregation: 'sum' },
351
928
  { field: 'amount', name: 'avgOrder', aggregation: 'avg' },
929
+ { field: 'amount', name: 'maxOrder', aggregation: 'max' },
352
930
  { field: 'recordId', name: 'orderCount', aggregation: 'count' },
353
931
  ],
932
+ dateRange: { start: Date.now() - 30 * 24 * 60 * 60 * 1000 },
354
933
  });
355
- // → { totalRevenue: 48200, avgOrder: 96.4, orderCount: 500 }
934
+ // → { totalRevenue: 48200, avgOrder: 96.4, maxOrder: 999, orderCount: 500 }
935
+ ```
936
+
937
+ ---
938
+
939
+ ### Storage Stats (Analytics)
940
+
941
+ Record count and byte statistics for the bucket.
356
942
 
357
- // Compare a metric across buckets
943
+ ```ts
944
+ const storageInfo = await analytics.storageStats({
945
+ dateRange: { start: new Date('2025-01-01').getTime() },
946
+ });
947
+ // → { totalRecords, totalBytes, avgBytes, minBytes, maxBytes }
948
+ ```
949
+
950
+ ---
951
+
952
+ ### Cross-Bucket
953
+
954
+ Compare the same metric across multiple buckets in a single query. Your key must have read access to all listed buckets. System buckets are blocked.
955
+
956
+ ```ts
358
957
  const compare = await analytics.crossBucket({
359
958
  bucketKeys: ['orders-2024', 'orders-2025'],
360
959
  field: 'amount',
361
960
  aggregation: 'sum',
961
+ dateRange: { start: new Date('2025-01-01').getTime() },
362
962
  });
963
+ // → [{ bucket: 'orders-2024', value: 38200 }, { bucket: 'orders-2025', value: 52100 }]
964
+ ```
965
+
966
+ ---
967
+
968
+ ### Raw Query
969
+
970
+ Escape hatch for query types not covered by the typed helpers.
363
971
 
364
- // All queries accept an optional date range
365
- const { count } = await analytics.count({
366
- dateRange: { start: Date.now() - 7 * 24 * 60 * 60 * 1000, end: Date.now() },
972
+ ```ts
973
+ import type { AnalyticsQuery, AnalyticsResult } from 'hydrousdb';
974
+
975
+ const result = await analytics.query<MyResultType>({
976
+ queryType: 'distribution',
977
+ field: 'category',
978
+ granularity: 'month',
979
+ filters: [{ field: 'active', op: '==', value: true }],
980
+ dateRange: { start: Date.now() - 30 * 24 * 60 * 60 * 1000 },
981
+ limit: 50,
982
+ order: 'desc',
367
983
  });
368
984
  ```
369
985
 
@@ -372,37 +988,52 @@ const { count } = await analytics.count({
372
988
  ## Error Handling
373
989
 
374
990
  ```ts
375
- import { HydrousError, NetworkError } from 'hydrousdb';
991
+ import {
992
+ HydrousError,
993
+ AuthError,
994
+ RecordError,
995
+ StorageError,
996
+ AnalyticsError,
997
+ ValidationError,
998
+ NetworkError,
999
+ } from 'hydrousdb';
376
1000
 
377
1001
  try {
378
1002
  await db.auth().login({ email: 'x@x.com', password: 'wrong' });
379
1003
  } catch (err) {
380
- if (err instanceof HydrousError) {
381
- console.log(err.code); // e.g. 'INVALID_CREDENTIALS'
382
- console.log(err.status); // e.g. 401
383
- console.log(err.message); // human-readable
1004
+ if (err instanceof AuthError) {
1005
+ console.log(err.code); // 'INVALID_CREDENTIALS'
1006
+ console.log(err.status); // 401
1007
+ console.log(err.message); // human-readable
1008
+ console.log(err.requestId); // for support
1009
+ }
1010
+
1011
+ if (err instanceof ValidationError) {
1012
+ console.log(err.details); // string[] of specific validation failures
384
1013
  }
1014
+
385
1015
  if (err instanceof NetworkError) {
386
1016
  console.log('No internet or server unreachable');
1017
+ console.log(err.cause); // original error
387
1018
  }
388
1019
  }
389
1020
  ```
390
1021
 
391
1022
  | Error class | When it's thrown |
392
1023
  |---|---|
393
- | `HydrousError` | Base class — all SDK errors extend this |
394
- | `AuthError` | Login failures, invalid sessions, permission denied |
395
- | `RecordError` | Record not found, validation failures |
1024
+ | `HydrousError` | Base class — all SDK errors extend this. Has `code`, `status`, `requestId`, `details`. |
1025
+ | `AuthError` | Login failures, invalid/expired sessions, permission denied |
1026
+ | `RecordError` | Record not found, write validation failures |
396
1027
  | `StorageError` | Upload/download failures, file not found |
397
1028
  | `AnalyticsError` | Invalid query, bucket not found |
398
- | `NetworkError` | No network, request timed out |
399
- | `ValidationError` | Bad input caught client-side before the request is sent |
1029
+ | `ValidationError` | Bad input caught client-side before the request is sent. Has `details: string[]`. |
1030
+ | `NetworkError` | No network, server unreachable, request timed out. Has `cause`. |
400
1031
 
401
1032
  ---
402
1033
 
403
1034
  ## TypeScript
404
1035
 
405
- The SDK is written in TypeScript. Type your records for full autocomplete:
1036
+ The SDK is written in TypeScript. Type your records for full autocomplete and safety:
406
1037
 
407
1038
  ```ts
408
1039
  interface Order {
@@ -421,6 +1052,27 @@ const order = await orders.create({
421
1052
  items: [{ sku: 'SHOE-42', qty: 1 }],
422
1053
  });
423
1054
  // order.status → 'pending' | 'paid' | 'refunded' ✓
1055
+ // order.id, order.createdAt, order.updatedAt are automatically added ✓
1056
+
1057
+ const result = await orders.query({
1058
+ filters: [{ field: 'status', op: '==', value: 'paid' }],
1059
+ });
1060
+ // result.records → (Order & RecordResult)[] ✓
1061
+ ```
1062
+
1063
+ Key exported types:
1064
+
1065
+ ```ts
1066
+ import type {
1067
+ HydrousConfig,
1068
+ RecordData, RecordResult, QueryOptions, QueryFilter, QueryResult,
1069
+ DateRange, Granularity, Aggregation, SortOrder,
1070
+ AnalyticsQuery, AnalyticsResult, AnalyticsFilter,
1071
+ UserRecord, AuthResult, Session,
1072
+ UploadOptions, UploadResult,
1073
+ ListOptions, ListResult, FileMetadata, SignedUrlResult,
1074
+ StorageStats,
1075
+ } from 'hydrousdb';
424
1076
  ```
425
1077
 
426
1078
  ---
@@ -428,25 +1080,34 @@ const order = await orders.create({
428
1080
  ## Security
429
1081
 
430
1082
  - **Never commit API keys.** Use environment variables (`process.env.…`).
431
- - **Never expose keys in the browser.** For client-side apps, proxy requests through your own backend.
432
- - **Keys travel in headers only** — the SDK enforces this. They never appear in URLs, access logs, or browser history.
1083
+ - **Never expose keys in the browser.** For client-side apps, proxy all SDK calls through your own backend.
1084
+ - **Keys travel in headers only** — the SDK enforces this. They never appear in URLs, query strings, access logs, or browser history.
433
1085
  - **Files are private by default.** `isPublic` defaults to `false`. Use `getSignedUrl()` for temporary external sharing.
434
- - **Use scoped storage** (`storage.scope('prefix/')`) to isolate files per user.
1086
+ - **Use scoped storage** (`storage.scope('prefix/')`) to isolate files per user and prevent path-traversal bugs.
435
1087
 
436
1088
  ---
437
1089
 
438
- ## Framework examples
1090
+ ## Framework Examples
439
1091
 
440
1092
  **Next.js (App Router)**
441
1093
  ```ts
442
1094
  // lib/db.ts
443
1095
  import { createClient } from 'hydrousdb';
444
- export const db = createClient({ … });
1096
+ export const db = createClient({
1097
+ authKey: process.env.HYDROUS_AUTH_KEY!,
1098
+ bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
1099
+ storageKeys: { main: process.env.HYDROUS_STORAGE_KEY! },
1100
+ });
445
1101
 
446
1102
  // app/api/posts/route.ts
447
1103
  import { db } from '@/lib/db';
448
1104
  export async function GET() {
449
- const { records } = await db.records('posts').query({ limit: 10 });
1105
+ const { records } = await db.records('posts').query({
1106
+ filters: [{ field: 'status', op: '==', value: 'published' }],
1107
+ orderBy: 'createdAt',
1108
+ order: 'desc',
1109
+ limit: 10,
1110
+ });
450
1111
  return Response.json(records);
451
1112
  }
452
1113
  ```
@@ -462,14 +1123,22 @@ const data = await res.json();
462
1123
  ```ts
463
1124
  // composables/useDb.ts
464
1125
  import { createClient } from 'hydrousdb';
465
- export const db = createClient({ … });
1126
+ export const db = createClient({
1127
+ authKey: import.meta.env.VITE_HYDROUS_AUTH_KEY,
1128
+ bucketSecurityKey: import.meta.env.VITE_HYDROUS_BUCKET_KEY,
1129
+ storageKeys: { main: import.meta.env.VITE_HYDROUS_STORAGE_KEY },
1130
+ });
466
1131
  ```
467
1132
 
468
1133
  **React Native**
469
1134
  ```ts
470
1135
  import { createClient } from 'hydrousdb';
471
1136
  // Works out of the box — uses the global fetch available in React Native
472
- const db = createClient({ … });
1137
+ const db = createClient({
1138
+ authKey: HYDROUS_AUTH_KEY,
1139
+ bucketSecurityKey: HYDROUS_BUCKET_KEY,
1140
+ storageKeys: { main: HYDROUS_STORAGE_KEY },
1141
+ });
473
1142
  ```
474
1143
 
475
1144
  ---
@@ -478,79 +1147,121 @@ const db = createClient({ … });
478
1147
 
479
1148
  ### `createClient(config)`
480
1149
 
1150
+ | Field | Type | Required | Description |
1151
+ |---|---|---|---|
1152
+ | `authKey` | `string` | ✓ | `hk_auth_…` — for auth routes |
1153
+ | `bucketSecurityKey` | `string` | ✓ | `hk_bucket_…` — for records & analytics |
1154
+ | `storageKeys` | `{ [name]: string }` | ✓ | One or more `ssk_…` keys for file storage |
1155
+ | `baseUrl` | `string` | — | Override the API endpoint (no trailing slash) |
1156
+
1157
+ ---
1158
+
1159
+ ### `db.records<T>(bucket)` — all methods
1160
+
1161
+ | Method | Returns | Description |
1162
+ |---|---|---|
1163
+ | `create(data, opts?)` | `T & RecordResult` | Create a record. `opts.queryableFields` enables filtering. `opts.customRecordId` enables upsert. |
1164
+ | `get(id)` | `T & RecordResult` | Fetch a record by ID. |
1165
+ | `patch(id, data, opts?)` | `{ id, updatedAt? }` | Partial update. `opts.merge` for deep merge. `opts.trackHistory` to save a version. |
1166
+ | `delete(id)` | `void` | Permanently delete a record. |
1167
+ | `exists(id)` | `boolean` | Lightweight existence check (HEAD request). |
1168
+ | `query(opts?)` | `QueryResult<T>` | Filter, sort, paginate. Supports `dateRange`. |
1169
+ | `getAll(opts?)` | `(T & RecordResult)[]` | Fetch all records (no filter support — use `query` for filters). |
1170
+ | `batchCreate(items, opts?)` | `{ results, errors, successful, failed }` | Up to 500 records at once. |
1171
+ | `batchUpdate(updates, userEmail?)` | `{ successful, failed }` | Up to 500 records at once. |
1172
+ | `batchDelete(ids, userEmail?)` | `{ successful, failed }` | Up to 500 records at once. |
1173
+ | `getHistory(id)` | `RecordHistoryEntry[]` | List all saved versions. |
1174
+ | `getVersion(id, generation)` | `T & RecordResult` | Fetch a specific past version. |
1175
+
1176
+ **`QueryOptions` fields:**
1177
+
481
1178
  | Field | Type | Description |
482
1179
  |---|---|---|
483
- | `authKey` | `string` | `hk_auth_…` for auth routes |
484
- | `bucketSecurityKey` | `string` | `hk_bucket_…` for records & analytics |
485
- | `storageKeys` | `{ [name]: string }` | One or more `ssk_…` keys for storage |
486
- | `baseUrl` | `string` | Override the endpoint (optional) |
1180
+ | `filters` | `QueryFilter[]` | Array of `{ field, op, value }` |
1181
+ | `fields` | `string` | Comma-separated list of fields to return |
1182
+ | `orderBy` | `string` | Field to sort by |
1183
+ | `order` | `'asc' \| 'desc'` | Sort direction |
1184
+ | `limit` | `number` | Max records to return |
1185
+ | `offset` | `number` | Skip N records |
1186
+ | `startAfter` | `string` | Cursor from `nextCursor` for next-page pagination |
1187
+ | `startAt` | `string` | Cursor — include the record at this cursor |
1188
+ | `endAt` | `string` | Cursor — stop at this cursor |
1189
+ | `dateRange` | `DateRange` | `{ start?, end? }` in Unix ms |
1190
+ | `timeScope` | `string` | Prefix-based time filter: `_day_YYMMDD`, `_month_YYMM`, or `_year_YY` |
487
1191
 
488
- ### `db.records(bucket)` — key methods
1192
+ ---
489
1193
 
490
- | Method | What it does |
491
- |---|---|
492
- | `create(data, opts?)` | Create a record. Use `opts.queryableFields` to enable filtering. |
493
- | `get(id)` | Fetch by ID. |
494
- | `patch(id, data)` | Partial update — only the fields you pass are changed. |
495
- | `delete(id)` | Permanent delete. |
496
- | `query(opts?)` | Filter, sort, paginate. |
497
- | `batchCreate(items, opts?)` | Up to 500 at once. |
498
- | `batchUpdate(updates)` | Up to 500 at once. |
499
- | `batchDelete(ids)` | Up to 500 at once. |
500
- | `getHistory(id)` | Full version list. |
501
- | `getVersion(id, generation)` | Read a past version. |
502
- | `exists(id)` | Lightweight existence check (HEAD request). |
503
-
504
- ### `db.auth()` — key methods
505
-
506
- | Method | What it does |
1194
+ ### `db.auth()` all methods
1195
+
1196
+ | Method | Description |
507
1197
  |---|---|
508
- | `signup(opts)` | Register + create session. |
1198
+ | `signup(opts)` | Register + create session. Extra fields on `opts` are stored on the user. |
509
1199
  | `login(opts)` | Authenticate + create session. |
510
- | `logout({ sessionId })` | Revoke session(s). |
511
- | `validateSession(sessionId)` | Check if a session is active. |
1200
+ | `logout({ sessionId, allDevices? })` | Revoke one session or all sessions. |
1201
+ | `validateSession(sessionId)` | Check if a session is active; returns current user. |
512
1202
  | `refreshSession(refreshToken)` | Get a new session from a refresh token. |
513
1203
  | `getUser(userId)` | Fetch a user by ID. |
514
1204
  | `updateUser(opts)` | Update profile fields. |
515
- | `changePassword(opts)` | Authenticated password change. |
516
- | `requestPasswordReset(email)` | Trigger reset email. |
517
- | `confirmPasswordReset(token, newPw)` | Apply new password. |
518
- | `listUsers(opts)` | Paginated user list (admin). |
519
- | `lockAccount(opts)` / `unlockAccount(…)` | Admin account controls. |
520
- | `hardDeleteUser(sessionId, userId)` | Permanent delete (admin). |
1205
+ | `changePassword(opts)` | Authenticated password change (requires current password). |
1206
+ | `requestPasswordReset(email)` | Send reset email (always succeeds to prevent enumeration). |
1207
+ | `confirmPasswordReset(token, newPw)` | Apply new password from reset token. |
1208
+ | `requestEmailVerification(userId)` | Send verification email. |
1209
+ | `confirmEmailVerification(token)` | Mark email verified from token. |
1210
+ | `getUser(userId)` | Fetch a user by ID. |
1211
+ | `listUsers(opts)` | Paginated user list. Admin only. |
1212
+ | `lockAccount(opts)` | Lock a user account. Admin only. |
1213
+ | `unlockAccount(sessionId, userId)` | Unlock a user account. Admin only. |
1214
+ | `hardDeleteUser(sessionId, userId)` | Permanent delete. Admin only. |
1215
+ | `bulkDeleteUsers(opts)` | Delete many users at once (soft or hard). Admin only. |
1216
+
1217
+ ---
521
1218
 
522
- ### `db.storage(keyName)` — key methods
1219
+ ### `db.storage(keyName)` — all methods
523
1220
 
524
- | Method | What it does |
1221
+ | Method | Description |
525
1222
  |---|---|
526
1223
  | `upload(data, path, opts?)` | Server-buffered upload (up to 500 MB). |
527
1224
  | `uploadRaw(data, path, opts?)` | Upload a JS object or string as a file. |
528
- | `getUploadUrl(opts)` | Step 1 of direct-to-GCS upload (for progress tracking). |
529
- | `uploadToSignedUrl(url, data, mime, onProgress?)` | Step 2 — upload directly to GCS. |
530
- | `confirmUpload(opts)` | Step 3 — register metadata. |
531
- | `download(path)` | Download a private file as ArrayBuffer. |
532
- | `list(opts?)` | List files and folders. |
533
- | `getMetadata(path)` | File size, MIME, visibility, URLs. |
534
- | `getSignedUrl(path, expiresIn?)` | Time-limited share link. |
535
- | `setVisibility(path, isPublic)` | Toggle public/private. |
536
- | `move(from, to)` / `copy(from, to)` | Move or copy files. |
537
- | `deleteFile(path)` / `deleteFolder(path)` | Delete files or folders. |
538
- | `scope(prefix)` | Get a path-prefixed sub-client. |
539
-
540
- ### `db.analytics(bucket)` key methods
541
-
542
- | Method | What it does |
543
- |---|---|
544
- | `count(opts?)` | Total record count. |
545
- | `distribution(opts)` | Per-value counts for a field. |
546
- | `sum(opts)` | Sum a numeric field, optional `groupBy`. |
547
- | `timeSeries(opts?)` | Record counts over time. |
548
- | `fieldTimeSeries(opts)` | Field aggregation over time. |
549
- | `topN(opts)` | Most frequent field values. |
550
- | `stats(opts)` | min / max / avg / sum / stddev. |
551
- | `multiMetric(opts)` | Multiple aggregations in one request. |
552
- | `crossBucket(opts)` | Compare a metric across buckets. |
553
- | `records(opts?)` | Filtered records via BigQuery. |
1225
+ | `getUploadUrl(opts)` | Step 1 of signed-URL upload get a GCS PUT URL. |
1226
+ | `uploadToSignedUrl(url, data, mime, onProgress?)` | Step 2 — upload directly to GCS (supports progress in browsers). |
1227
+ | `confirmUpload(opts)` | Step 3 — register metadata server-side. |
1228
+ | `getBatchUploadUrls(files)` | Get signed URLs for up to 50 files at once. |
1229
+ | `batchConfirmUploads(items)` | Confirm multiple direct uploads at once. |
1230
+ | `download(path)` | Download a private file as `ArrayBuffer`. |
1231
+ | `batchDownload(paths, concurrency?)` | Download up to 20 files at once (base64 content). |
1232
+ | `list(opts?)` | List files and folders. Supports `prefix`, `limit`, `cursor`, `recursive`. |
1233
+ | `getMetadata(path)` | File size, MIME type, visibility, URLs. |
1234
+ | `getSignedUrl(path, expiresIn?)` | Time-limited share link (default 3600 s). |
1235
+ | `setVisibility(path, isPublic)` | Toggle a file between public and private. |
1236
+ | `createFolder(path)` | Create an explicit folder marker. |
1237
+ | `move(from, to)` | Move (rename) a file. |
1238
+ | `copy(from, to)` | Copy a file to a new path. |
1239
+ | `deleteFile(path)` | Permanently delete a file. |
1240
+ | `deleteFolder(path)` | Recursively delete a folder and all its contents. |
1241
+ | `getStats()` | Upload/download/delete counts and total bytes. |
1242
+ | `info()` | Server info no auth required. |
1243
+ | `scope(prefix)` | Get a `ScopedStorage` that auto-prefixes all paths. |
1244
+
1245
+ `ScopedStorage` exposes every method above (except `info`) plus `scope(subPrefix)` for nesting deeper.
1246
+
1247
+ ---
1248
+
1249
+ ### `db.analytics(bucket)` all methods
1250
+
1251
+ | Method | Returns | Description |
1252
+ |---|---|---|
1253
+ | `count(opts?)` | `{ count }` | Total record count, optionally within a `dateRange`. |
1254
+ | `distribution(opts)` | `DistributionRow[]` | Per-value counts for a field. |
1255
+ | `sum(opts)` | `SumRow[]` | Sum a numeric field, optional `groupBy`. |
1256
+ | `timeSeries(opts?)` | `TimeSeriesRow[]` | Record counts bucketed by `granularity`. |
1257
+ | `fieldTimeSeries(opts)` | `FieldTimeSeriesRow[]` | Numeric field aggregated over time. |
1258
+ | `topN(opts)` | `TopNRow[]` | Most frequent field values. |
1259
+ | `stats(opts)` | `FieldStats` | min / max / avg / sum / count / stddev. |
1260
+ | `records(opts?)` | `(T & RecordResult)[]` | Filtered records via BigQuery (good for large sets). |
1261
+ | `multiMetric(opts)` | `MultiMetricResult` | Multiple aggregations in one request. |
1262
+ | `storageStats(opts?)` | `StorageStatsResult` | Record count and byte stats for the bucket. |
1263
+ | `crossBucket(opts)` | `CrossBucketRow[]` | Compare a metric across multiple buckets. |
1264
+ | `query(query)` | `AnalyticsResult<T>` | Raw escape hatch for any `AnalyticsQuery`. |
554
1265
 
555
1266
  ---
556
1267