hydrousdb 3.5.1 → 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
@@ -17,6 +17,7 @@ npm install hydrousdb
17
17
  - [Create, Read, Update, Delete](#create-read-update-delete)
18
18
  - [Querying & Filtering](#querying--filtering)
19
19
  - [Time Scope on Queries](#time-scope-on-queries)
20
+ - [Date Range Queries](#date-range-queries)
20
21
  - [Pagination](#pagination)
21
22
  - [Atomic Field Updates](#atomic-field-updates)
22
23
  - [Batch Operations](#batch-operations)
@@ -26,6 +27,7 @@ npm install hydrousdb
26
27
  - [Get All Records](#get-all-records)
27
28
  - [Auth](#auth)
28
29
  - [Sign Up & Log In](#sign-up--log-in)
30
+ - [Google Sign-In](#google-sign-in)
29
31
  - [Session Management](#session-management)
30
32
  - [User Profile](#user-profile)
31
33
  - [Password & Email](#password--email)
@@ -143,11 +145,37 @@ const { records, hasMore, nextCursor } = await posts.query({
143
145
 
144
146
  **Supported filter operators:** `==` `!=` `>` `<` `>=` `<=` `contains`
145
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
+
146
174
  ---
147
175
 
148
176
  ### Time Scope on Queries
149
177
 
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.
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.
151
179
 
152
180
  | Scope | Format | Example | Matches |
153
181
  |---|---|---|---|
@@ -185,7 +213,7 @@ const { records: yearRecords } = await posts.query({
185
213
  limit: 100,
186
214
  });
187
215
 
188
- // Fully composable with filters
216
+ // Fully composable with filters — this is the recommended pattern for large buckets
189
217
  const { records: published } = await posts.query({
190
218
  timeScope: '_month_2603',
191
219
  filters: [{ field: 'status', op: '==', value: 'published' }],
@@ -199,6 +227,49 @@ const all = await posts.getAll({ timeScope: '_year_26' });
199
227
 
200
228
  ---
201
229
 
230
+ ### Date Range Queries
231
+
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
+
202
273
  ### Pagination
203
274
 
204
275
  `query()` returns a cursor you can pass straight into the next call.
@@ -229,15 +300,16 @@ Avoid race conditions with server-side sentinels inside `patch()`:
229
300
 
230
301
  ```ts
231
302
  await posts.patch(post.id, {
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
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
241
313
  } as any);
242
314
  ```
243
315
 
@@ -341,7 +413,7 @@ const all = await posts.getAll({
341
413
 
342
414
  ## Auth
343
415
 
344
- 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.
345
417
 
346
418
  ```ts
347
419
  const auth = db.auth();
@@ -380,6 +452,118 @@ Store `session.sessionId` and `session.refreshToken` in your app.
380
452
 
381
453
  ---
382
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
+ }
563
+ ```
564
+
565
+ ---
566
+
383
567
  ### Session Management
384
568
 
385
569
  ```ts
@@ -423,7 +607,9 @@ interface UserRecord {
423
607
  createdAt: number; // Unix ms
424
608
  updatedAt: number; // Unix ms
425
609
  metadata?: Record<string, unknown>;
426
- [key: string]: unknown; // custom fields from signup
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
427
613
  }
428
614
  ```
429
615
 
@@ -433,6 +619,7 @@ interface UserRecord {
433
619
 
434
620
  ```ts
435
621
  // Change password — requires an active session AND the current password
622
+ // Pass empty string for currentPassword to SET a first password (Google users)
436
623
  await auth.changePassword({
437
624
  sessionId: session.sessionId,
438
625
  userId: user.id,
@@ -632,7 +819,6 @@ const meta = await storage.getMetadata('avatars/alice.jpg');
632
819
  // → { path, size, mimeType, isPublic, publicUrl, downloadUrl, createdAt, updatedAt }
633
820
 
634
821
  // 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
822
  const { signedUrl, expiresAt, expiresIn } = await storage.getSignedUrl(
637
823
  'private/report.pdf',
638
824
  3600, // lifetime in seconds (default: 3600)
@@ -690,7 +876,7 @@ const reports = userFiles.scope('reports/'); // → users/{userId}/reports
690
876
  await reports.upload(file, 'q1.pdf'); // → users/{userId}/reports/q1.pdf
691
877
 
692
878
  // All StorageManager methods are available on ScopedStorage
693
- const meta = await userFiles.getMetadata('contract.pdf');
879
+ const meta = await userFiles.getMetadata('contract.pdf');
694
880
  const { signedUrl } = await userFiles.getSignedUrl('contract.pdf', 900);
695
881
  await userFiles.move('old.pdf', 'new.pdf');
696
882
  await userFiles.deleteFile('contract.pdf');
@@ -723,7 +909,7 @@ const analytics = db.analytics('orders');
723
909
 
724
910
  ### Date Range (Time Scope)
725
911
 
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.
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.
727
913
 
728
914
  ```ts
729
915
  interface DateRange {
@@ -779,7 +965,7 @@ How many records have each value of a field.
779
965
  const dist = await analytics.distribution({
780
966
  field: 'status',
781
967
  limit: 10,
782
- order: 'desc', // 'asc' | 'desc'
968
+ order: 'desc',
783
969
  dateRange: { start: new Date('2025-01-01').getTime() },
784
970
  });
785
971
  // → [{ value: 'published', count: 320 }, { value: 'draft', count: 80 }, …]
@@ -846,7 +1032,6 @@ const revTrend = await analytics.fieldTimeSeries({
846
1032
  end: Date.now(),
847
1033
  },
848
1034
  });
849
- // → [{ date: '2025-01-01', value: 4820.5 }, …]
850
1035
 
851
1036
  // Monthly average order value
852
1037
  const avgTrend = await analytics.fieldTimeSeries({
@@ -863,11 +1048,10 @@ const avgTrend = await analytics.fieldTimeSeries({
863
1048
  Most frequent values for a field by record count.
864
1049
 
865
1050
  ```ts
866
- // Top 10 countries
867
1051
  const top10 = await analytics.topN({
868
1052
  field: 'countryCode',
869
1053
  n: 10,
870
- labelField: 'countryName', // optional — include a human-readable label alongside the value
1054
+ labelField: 'countryName',
871
1055
  order: 'desc',
872
1056
  dateRange: { start: new Date('2025-01-01').getTime() },
873
1057
  });
@@ -892,7 +1076,7 @@ const priceStats = await analytics.stats({
892
1076
 
893
1077
  ### Records via BigQuery
894
1078
 
895
- Fetch filtered records through the BigQuery engine instead of Firestore. Useful for large result sets or complex server-side filtering.
1079
+ Fetch filtered records through the BigQuery engine instead of the GCS index. Useful for large result sets or complex server-side filtering.
896
1080
 
897
1081
  ```ts
898
1082
  const records = await analytics.records<Order>({
@@ -901,7 +1085,7 @@ const records = await analytics.records<Order>({
901
1085
  { field: 'amount', op: '>=', value: 100 },
902
1086
  { field: 'country', op: 'CONTAINS', value: 'US' },
903
1087
  ],
904
- selectFields: ['id', 'amount', 'country', 'createdAt'], // optional projection
1088
+ selectFields: ['id', 'amount', 'country', 'createdAt'],
905
1089
  orderBy: 'createdAt',
906
1090
  order: 'desc',
907
1091
  limit: 1000,
@@ -951,7 +1135,7 @@ const storageInfo = await analytics.storageStats({
951
1135
 
952
1136
  ### Cross-Bucket
953
1137
 
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.
1138
+ Compare the same metric across multiple buckets in a single query.
955
1139
 
956
1140
  ```ts
957
1141
  const compare = await analytics.crossBucket({
@@ -1069,6 +1253,7 @@ import type {
1069
1253
  DateRange, Granularity, Aggregation, SortOrder,
1070
1254
  AnalyticsQuery, AnalyticsResult, AnalyticsFilter,
1071
1255
  UserRecord, AuthResult, Session,
1256
+ GoogleSignInOptions, GoogleLinkOptions,
1072
1257
  UploadOptions, UploadResult,
1073
1258
  ListOptions, ListResult, FileMetadata, SignedUrlResult,
1074
1259
  StorageStats,
@@ -1084,6 +1269,7 @@ import type {
1084
1269
  - **Keys travel in headers only** — the SDK enforces this. They never appear in URLs, query strings, access logs, or browser history.
1085
1270
  - **Files are private by default.** `isPublic` defaults to `false`. Use `getSignedUrl()` for temporary external sharing.
1086
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.
1087
1273
 
1088
1274
  ---
1089
1275
 
@@ -1165,7 +1351,7 @@ const db = createClient({
1165
1351
  | `patch(id, data, opts?)` | `{ id, updatedAt? }` | Partial update. `opts.merge` for deep merge. `opts.trackHistory` to save a version. |
1166
1352
  | `delete(id)` | `void` | Permanently delete a record. |
1167
1353
  | `exists(id)` | `boolean` | Lightweight existence check (HEAD request). |
1168
- | `query(opts?)` | `QueryResult<T>` | Filter, sort, paginate. Supports `dateRange`. |
1354
+ | `query(opts?)` | `QueryResult<T>` | Filter, sort, paginate. Supports `dateRange`, `timeScope`, `startDate`, `endDate`, `year`, `sortBy`. |
1169
1355
  | `getAll(opts?)` | `(T & RecordResult)[]` | Fetch all records (no filter support — use `query` for filters). |
1170
1356
  | `batchCreate(items, opts?)` | `{ results, errors, successful, failed }` | Up to 500 records at once. |
1171
1357
  | `batchUpdate(updates, userEmail?)` | `{ successful, failed }` | Up to 500 records at once. |
@@ -1179,7 +1365,8 @@ const db = createClient({
1179
1365
  |---|---|---|
1180
1366
  | `filters` | `QueryFilter[]` | Array of `{ field, op, value }` |
1181
1367
  | `fields` | `string` | Comma-separated list of fields to return |
1182
- | `orderBy` | `string` | Field to sort by |
1368
+ | `orderBy` | `string` | Field to sort by (server maps to `sortBy`) |
1369
+ | `sortBy` | `string` | Alias for `orderBy` — maps directly to `?sortBy=` |
1183
1370
  | `order` | `'asc' \| 'desc'` | Sort direction |
1184
1371
  | `limit` | `number` | Max records to return |
1185
1372
  | `offset` | `number` | Skip N records |
@@ -1188,6 +1375,9 @@ const db = createClient({
1188
1375
  | `endAt` | `string` | Cursor — stop at this cursor |
1189
1376
  | `dateRange` | `DateRange` | `{ start?, end? }` in Unix ms |
1190
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 |
1191
1381
 
1192
1382
  ---
1193
1383
 
@@ -1197,17 +1387,20 @@ const db = createClient({
1197
1387
  |---|---|
1198
1388
  | `signup(opts)` | Register + create session. Extra fields on `opts` are stored on the user. |
1199
1389
  | `login(opts)` | Authenticate + create session. |
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). |
1200
1393
  | `logout({ sessionId, allDevices? })` | Revoke one session or all sessions. |
1201
1394
  | `validateSession(sessionId)` | Check if a session is active; returns current user. |
1202
1395
  | `refreshSession(refreshToken)` | Get a new session from a refresh token. |
1203
1396
  | `getUser(userId)` | Fetch a user by ID. |
1204
1397
  | `updateUser(opts)` | Update profile fields. |
1205
- | `changePassword(opts)` | Authenticated password change (requires current password). |
1398
+ | `deleteUser(sessionId, userId)` | Soft-delete a user. |
1399
+ | `changePassword(opts)` | Authenticated password change. Pass `currentPassword: ''` to set first password on Google accounts. |
1206
1400
  | `requestPasswordReset(email)` | Send reset email (always succeeds to prevent enumeration). |
1207
1401
  | `confirmPasswordReset(token, newPw)` | Apply new password from reset token. |
1208
- | `requestEmailVerification(userId)` | Send verification email. |
1402
+ | `requestEmailVerification(userId)` | Send verification email. Not needed for Google users. |
1209
1403
  | `confirmEmailVerification(token)` | Mark email verified from token. |
1210
- | `getUser(userId)` | Fetch a user by ID. |
1211
1404
  | `listUsers(opts)` | Paginated user list. Admin only. |
1212
1405
  | `lockAccount(opts)` | Lock a user account. Admin only. |
1213
1406
  | `unlockAccount(sessionId, userId)` | Unlock a user account. Admin only. |
@@ -1242,8 +1435,6 @@ const db = createClient({
1242
1435
  | `info()` | Server info — no auth required. |
1243
1436
  | `scope(prefix)` | Get a `ScopedStorage` that auto-prefixes all paths. |
1244
1437
 
1245
- `ScopedStorage` exposes every method above (except `info`) plus `scope(subPrefix)` for nesting deeper.
1246
-
1247
1438
  ---
1248
1439
 
1249
1440
  ### `db.analytics(bucket)` — all methods