hydrousdb 3.5.0 → 3.5.2

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