hydrousdb 3.2.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
@@ -1,723 +1,486 @@
1
- # HydrousDB JavaScript / TypeScript SDK
1
+ # HydrousDB JS/TS SDK
2
2
 
3
- <p align="center">
4
- <strong>Store, retrieve, and query massive JSON records in milliseconds — with auth, file storage, and analytics built in.</strong><br><br>
5
- <a href="https://hydrousdb.com/dashboard"><strong>→ Create a free account and run your first query in 5 minutes</strong></a>
6
- </p>
3
+ **A database that doesn't choke on big JSON.** Store, query, and analyse massive records — with auth and file storage built in.
7
4
 
8
- <p align="center">
9
- <a href="https://www.npmjs.com/package/hydrousdb"><img src="https://img.shields.io/npm/v/hydrousdb.svg" alt="npm version"></a>
10
- <a href="https://www.npmjs.com/package/hydrousdb"><img src="https://img.shields.io/npm/dm/hydrousdb.svg" alt="npm downloads"></a>
11
- <a href="https://github.com/hydrousdb/hydrousdb-js/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/hydrousdb.svg" alt="MIT License"></a>
12
- <a href="https://hydrousdb.com"><img src="https://img.shields.io/badge/docs-hydrousdb.com-blue" alt="Documentation"></a>
13
- </p>
5
+ ```bash
6
+ npm install hydrousdb
7
+ ```
8
+
9
+ [Get your free API keys at hydrousdb.com](https://hydrousdb.com/dashboard)
14
10
 
15
11
  ---
16
12
 
17
13
  ## Table of Contents
18
14
 
19
- - [What is HydrousDB?](#what-is-hydrousdb)
20
- - [How It Works](#how-it-works)
21
- - [Quick Start](#quick-start)
22
- - [Installation](#installation)
23
- - [Module Formats — ESM & CommonJS](#module-formats--esm--commonjs)
15
+ - [Setup](#setup)
24
16
  - [Records](#records)
25
- - [Create](#create-a-record)
26
- - [Read](#read-a-record)
27
- - [Update patch vs set](#update-a-record)
28
- - [Delete](#delete-a-record)
29
- - [Query](#query-records)
30
- - [Count](#count-records)
31
- - [Batch Create](#batch-create)
32
- - [Batch Delete](#batch-delete)
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)
33
23
  - [Version History](#version-history)
34
- - [Write-Filter Sentinels](#write-filter-sentinels)
35
24
  - [Custom Record IDs](#custom-record-ids)
36
- - [Authentication](#authentication)
37
- - [Sign Up](#sign-up)
38
- - [Log In / Log Out](#log-in--log-out)
25
+ - [Existence Check](#existence-check)
26
+ - [Get All Records](#get-all-records)
27
+ - [Auth](#auth)
28
+ - [Sign Up & Log In](#sign-up--log-in)
39
29
  - [Session Management](#session-management)
40
- - [Validate a Session](#validate-a-session)
41
- - [Update Profile](#update-profile)
42
- - [Change Password](#change-password)
43
- - [Password Reset Flow](#password-reset-flow)
44
- - [Email Verification](#email-verification)
45
- - [Admin — List Users](#admin--list-users)
46
- - [Admin — Lock / Unlock](#admin--lock--unlock)
47
- - [Admin — Delete Users](#admin--delete-users)
30
+ - [User Profile](#user-profile)
31
+ - [Password & Email](#password--email)
32
+ - [Admin Controls](#admin-controls)
48
33
  - [File Storage](#file-storage)
49
- - [Simple Upload](#simple-upload)
50
- - [Upload Raw JSON or Text](#upload-raw-json-or-text)
51
- - [Large File Upload with Progress](#large-file-upload-with-progress)
52
- - [Batch Upload](#batch-upload)
34
+ - [Upload](#upload)
35
+ - [Large Files with Progress](#large-files-with-progress)
36
+ - [Batch Uploads](#batch-uploads)
53
37
  - [Download](#download)
54
38
  - [Batch Download](#batch-download)
55
- - [List Files](#list-files)
56
- - [Scoped Storage](#scoped-storage)
57
- - [File Metadata](#file-metadata)
58
- - [Signed Share URLs](#signed-share-urls)
39
+ - [List, Metadata & Signed URLs](#list-metadata--signed-urls)
40
+ - [Move, Copy & Delete](#move-copy--delete)
59
41
  - [Visibility](#visibility)
60
- - [Move, Copy, Delete](#move-copy-delete)
42
+ - [Folders](#folders)
43
+ - [Scoped Storage](#scoped-storage)
61
44
  - [Storage Stats](#storage-stats)
62
45
  - [Analytics](#analytics)
63
- - [Count](#count-1)
46
+ - [Date Range (Time Scope)](#date-range-time-scope)
47
+ - [Count](#count)
64
48
  - [Distribution](#distribution)
65
49
  - [Sum](#sum)
66
50
  - [Time Series](#time-series)
67
51
  - [Field Time Series](#field-time-series)
68
52
  - [Top N](#top-n)
69
- - [Field Stats](#field-stats)
70
- - [Multi-Metric Dashboard](#multi-metric-dashboard)
71
- - [Filtered Records via BigQuery](#filtered-records-via-bigquery)
72
- - [Cross-Bucket Comparison](#cross-bucket-comparison)
53
+ - [Stats](#stats)
54
+ - [Records via BigQuery](#records-via-bigquery)
55
+ - [Multi-Metric](#multi-metric)
73
56
  - [Storage Stats](#storage-stats-1)
57
+ - [Cross-Bucket](#cross-bucket)
74
58
  - [Raw Query](#raw-query)
75
- - [TypeScript](#typescript)
76
59
  - [Error Handling](#error-handling)
77
- - [Security Best Practices](#security-best-practices)
78
- - [Full API Reference](#full-api-reference)
79
- - [Contributing](#contributing)
80
- - [License](#license)
81
-
82
- ---
83
-
84
- ## What is HydrousDB?
85
-
86
- Traditional databases start choking when your JSON records get large. Postgres hits row-size limits. Firestore charges per field read. MongoDB buckles under millions of 500 KB+ documents. They were built for structured rows and small payloads — not the deeply nested, real-world JSON that modern apps actually produce.
87
-
88
- HydrousDB is built specifically for that problem. It stores every record as a compressed GCS blob, retrieves any record in a single network call (the storage path is computed directly from the record ID — no index lookups), and runs analytics at BigQuery scale without ETL. The bigger and messier your JSON, the more it outperforms traditional databases.
89
-
90
- | Domain | Example records | Why traditional DBs struggle |
91
- |---|---|---|
92
- | 🏥 **Hospital / EMR** | Full patient charts — vitals, medications, notes, imaging | 850 KB+ per chart, millions of patients, strict audit trails |
93
- | 🎓 **School management** | Student portfolios — grades, assessments, teacher notes | Deep nesting, bursty writes at term-end, long-term archival |
94
- | 🏭 **IoT / Industrial** | Sensor telemetry — readings, device state, calibration | Billions of records, append-heavy, rarely updated |
95
- | 🛒 **E-commerce** | Orders — line items, fulfilment events, return history | Variable shape, fast analytics across date ranges |
96
- | ⚖️ **Legal / compliance** | Case files — filings, correspondence, version history | 1 MB+ records, immutable audit log, cross-case analytics |
97
- | 🎮 **Gaming** | Player save states — inventory, quest progress, replays | Large payloads, millions of users, burst writes |
98
-
99
- **What you get out of the box:**
100
-
101
- | Feature | What it does |
102
- |---|---|
103
- | **Records** | Schemaless JSON store. Billion-scale, gzip-compressed, date-encoded IDs for zero-lookup retrieval. |
104
- | **Auth** | Full user system — signup, login, sessions, password reset, email verification, admin controls. |
105
- | **Storage** | File uploads to GCS. Direct uploads, public/private visibility, signed share URLs. |
106
- | **Analytics** | BigQuery-powered — counts, distributions, time series, top-N, cross-bucket. Zero ETL. |
107
-
108
- ---
109
-
110
- ## How It Works
111
-
112
- Every record ID encodes its creation date as a prefix (e.g. `260203-rec_01JA2XYZ`). This means the full GCS storage path to any record can be computed in memory — no index lookup needed.
113
-
114
- ```
115
- 260203-rec_01JA2XYZ
116
- ↓ parse date prefix
117
- YY=26 MM=02 DD=03
118
- ↓ compute path in memory
119
- projects/pid/buckets/bk/records/26/02/03/rec_01JA.json.gz
120
- ↓ fetch from GCS directly
121
- 0 index reads ✓
122
- ```
123
-
124
- Records are gzip-compressed on write (60–80% size reduction). An 850 KB hospital chart becomes ~255 KB on disk, automatically, every time.
60
+ - [TypeScript](#typescript)
61
+ - [Security](#security)
62
+ - [Framework Examples](#framework-examples)
63
+ - [API Reference](#api-reference)
125
64
 
126
65
  ---
127
66
 
128
- ## Quick Start
129
-
130
- ### 1. Create your account
131
-
132
- Sign up at [https://hydrousdb.com](https://hydrousdb.com).
133
-
134
- ### 2. Get your API keys
135
-
136
- From the dashboard → **Settings → API Keys**, create three key types:
137
-
138
- | Key | Prefix | Used for |
139
- |---|---|---|
140
- | **Auth Key** | `hk_auth_…` | All auth routes — signup, login, sessions |
141
- | **Bucket Security Key** | `hk_bucket_…` | Records and analytics |
142
- | **Storage Key(s)** | `ssk_…` | File storage — one key per storage bucket |
143
-
144
- > ⚠️ Never commit these to Git. Store them in environment variables.
145
-
146
- ### 3. Install
147
-
148
- ```bash
149
- npm install hydrousdb
150
- # or: yarn add hydrousdb / pnpm add hydrousdb
151
- ```
152
-
153
- **Requirements:** Node.js 18+ (uses the native `fetch` API).
154
-
155
- ### 4. Create the client and write your first record
67
+ ## Setup
156
68
 
157
- ```typescript
69
+ ```ts
158
70
  import { createClient } from 'hydrousdb';
159
71
 
160
- // Create once — reuse everywhere in your app
161
72
  const db = createClient({
162
- authKey: process.env.HYDROUS_AUTH_KEY!,
163
- bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
73
+ authKey: process.env.HYDROUS_AUTH_KEY!, // hk_auth_…
74
+ bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!, // hk_bucket_…
164
75
  storageKeys: {
165
- main: process.env.HYDROUS_STORAGE_MAIN!,
76
+ main: process.env.HYDROUS_STORAGE_KEY!, // ssk_…
77
+ // add more named storage keys as needed
166
78
  },
79
+ // baseUrl: 'https://custom-endpoint.example.com', // optional override
167
80
  });
168
-
169
- // Write
170
- const post = await db.records('my-bucket').create({
171
- title: 'Hello, HydrousDB!',
172
- published: false,
173
- });
174
-
175
- console.log(post.id); // "260601-rec_01JA2XYZ"
176
- console.log(post.createdAt); // 1717200000000
177
-
178
- // Read back — zero database reads, path computed from ID
179
- const fetched = await db.records('my-bucket').get(post.id);
180
-
181
- // Update
182
- await db.records('my-bucket').patch(post.id, { published: true });
183
-
184
- // Delete
185
- await db.records('my-bucket').delete(post.id);
186
81
  ```
187
82
 
188
- ---
189
-
190
- ## Installation
191
-
192
- ```bash
193
- npm install hydrousdb
194
- ```
195
-
196
- ### Module Formats — ESM & CommonJS
197
-
198
- The package ships both ESM (`.mjs`) and CommonJS (`.cjs`) builds. Your toolchain picks the right one automatically based on your `import` or `require` call.
199
-
200
- ```typescript
201
- // ESM — Next.js, Vite, modern Node, TypeScript
202
- import { createClient } from 'hydrousdb';
203
- ```
204
-
205
- ```javascript
206
- // CommonJS — legacy Node, Jest without transform, older tooling
207
- const { createClient } = require('hydrousdb');
208
- ```
83
+ You get three key types from the dashboard — one for auth, one for records/analytics, one (or more) for file storage. Keep them in environment variables, never in your code.
209
84
 
210
- Both exports are listed explicitly in `package.json` under the `exports` field:
211
-
212
- ```json
213
- {
214
- "exports": {
215
- ".": {
216
- "import": "./dist/index.mjs",
217
- "require": "./dist/index.cjs",
218
- "types": "./dist/index.d.ts"
219
- }
220
- }
221
- }
222
- ```
223
-
224
- **Using with Next.js?** If Next.js resolves the CJS build instead of ESM (common with the Pages Router or older Next configs), add this to `next.config.js`:
225
-
226
- ```javascript
227
- const nextConfig = {
228
- webpack(config) {
229
- config.resolve.conditionNames = ['import', 'module', 'require', 'default'];
230
- return config;
231
- },
232
- };
233
- ```
85
+ Works in **React, Next.js, Vue, React Native, Node.js** anywhere that runs modern JavaScript.
234
86
 
235
87
  ---
236
88
 
237
89
  ## Records
238
90
 
239
- Records are JSON objects stored in named buckets. Every record automatically gets:
240
-
241
- - `id` — date-prefixed unique identifier (`"260601-rec_01JA2XYZ"`) — encodes the GCS path
242
- - `createdAt` — Unix timestamp in milliseconds
243
- - `updatedAt` — Unix timestamp in milliseconds
91
+ JSON objects stored in named buckets. Every record automatically gets `id`, `createdAt`, and `updatedAt`.
244
92
 
245
- ```typescript
93
+ ```ts
246
94
  const posts = db.records('blog-posts');
247
- // or typed:
248
- const orders = db.records<Order>('orders');
249
- ```
250
-
251
- ### Create a Record
252
-
253
- ```typescript
254
- const post = await posts.create({
255
- title: 'My First Post',
256
- body: 'Hello world.',
257
- status: 'draft',
258
- views: 0,
259
- });
260
-
261
- // post.id, post.createdAt, post.updatedAt are added automatically
262
- console.log(post.id); // "260601-rec_01JA2XYZ"
263
95
  ```
264
96
 
265
- **With queryable fields** fields you want to filter on server-side must be declared at write time:
97
+ ### Create, Read, Update, Delete
266
98
 
267
- ```typescript
99
+ ```ts
100
+ // Create
268
101
  const post = await posts.create(
102
+ { title: 'Hello', status: 'draft', views: 0 },
269
103
  {
270
- title: 'My First Post',
271
- status: 'draft',
272
- authorId: 'usr_abc',
273
- },
274
- {
275
- queryableFields: ['status', 'authorId'], // index these for filtering
276
- userEmail: 'alice@example.com', // optional audit trail
104
+ queryableFields: ['status', 'views'], // declare fields you want to filter on
105
+ userEmail: 'alice@example.com', // optional — for audit trails
277
106
  },
278
107
  );
279
- ```
108
+ console.log(post.id); // "260601-rec_01JA2XYZ"
109
+ console.log(post.createdAt); // Unix ms timestamp
280
110
 
281
- > 💡 **Why declare queryable fields?** HydrousDB stores records as compressed blobs. Fields you want to filter or sort by need to be registered in a lightweight index at write time. You only pay index overhead for the fields you actually query.
111
+ // Read by ID
112
+ const found = await posts.get(post.id);
282
113
 
283
- ### Read a Record
114
+ // Partial update — only the fields you pass are changed
115
+ await posts.patch(post.id, { status: 'published' });
284
116
 
285
- ```typescript
286
- // Path computed from the ID in memory zero index reads
287
- const post = await posts.get('260601-rec_01JA2XYZ');
117
+ // Merge mode — deeply merge nested objects instead of replacing them
118
+ await posts.patch(post.id, { meta: { seo: true } }, { merge: true });
288
119
 
289
- // Throws HydrousError (code: RECORD_NOT_FOUND) if the ID doesn't exist
120
+ // Delete permanently
121
+ await posts.delete(post.id);
290
122
  ```
291
123
 
292
- ### Update a Record
293
-
294
- **`patch(id, data)` — merge update.** Only the fields you provide are changed. All other fields on the record are left untouched.
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.
295
125
 
296
- ```typescript
297
- const updated = await posts.patch('260601-rec_01JA2XYZ', {
298
- status: 'published',
299
- views: 1,
300
- });
301
- ```
126
+ ---
302
127
 
303
- **`set(id, data)` full replace.** The entire record is replaced with the new data.
128
+ ### Querying & Filtering
304
129
 
305
- ```typescript
306
- const replaced = await posts.set('260601-rec_01JA2XYZ', {
307
- title: 'Updated Title',
308
- body: 'New content.',
309
- status: 'published',
310
- views: 42,
130
+ ```ts
131
+ const { records, hasMore, nextCursor } = await posts.query({
132
+ filters: [
133
+ { field: 'status', op: '==', value: 'published' },
134
+ { field: 'views', op: '>', value: 100 },
135
+ { field: 'title', op: 'contains', value: 'hello' },
136
+ ],
137
+ orderBy: 'createdAt',
138
+ order: 'desc',
139
+ limit: 20,
140
+ fields: 'id,title,status', // optional — return only these fields
311
141
  });
312
142
  ```
313
143
 
314
- **Disable merge** (force field removal):
144
+ **Supported filter operators:** `==` `!=` `>` `<` `>=` `<=` `contains`
315
145
 
316
- ```typescript
317
- // merge: false means fields not in `data` are removed
318
- await posts.patch('260601-rec_01JA2XYZ', { status: 'archived' }, { merge: false });
319
- ```
320
-
321
- ### Delete a Record
322
-
323
- ```typescript
324
- await posts.delete('260601-rec_01JA2XYZ');
325
- ```
146
+ ---
326
147
 
327
- ### Query Records
148
+ ### Time Scope on Queries
328
149
 
329
- ```typescript
330
- // All records (up to 100 by default)
331
- const { records } = await posts.query();
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.
332
151
 
333
- // With filters
334
- const { records: published } = await posts.query({
335
- filters: [
336
- { field: 'status', op: '==', value: 'published' },
337
- ],
338
- });
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 |
339
157
 
340
- // Multiple filters, sort, limit
158
+ ```ts
159
+ // All records from March 2026
341
160
  const { records, hasMore, nextCursor } = await posts.query({
342
- filters: [
343
- { field: 'status', op: '==', value: 'published' },
344
- { field: 'views', op: '>', value: 100 },
345
- ],
346
- orderBy: 'createdAt',
347
- order: 'desc',
348
- limit: 20,
161
+ timeScope: '_month_2603',
162
+ order: 'desc',
163
+ limit: 50,
349
164
  });
350
165
 
351
- // Next page
166
+ // Paginate through a time-scoped result set
352
167
  if (hasMore) {
353
168
  const page2 = await posts.query({
354
- orderBy: 'createdAt',
355
- order: 'desc',
356
- limit: 20,
169
+ timeScope: '_month_2603',
357
170
  startAfter: nextCursor,
358
171
  });
359
172
  }
360
173
 
361
- // Select only specific fields (reduces payload size)
362
- const { records: light } = await posts.query({
363
- fields: 'id,title,status,createdAt',
174
+ // A specific day
175
+ const { records: dayRecords } = await posts.query({
176
+ timeScope: '_day_260305', // March 5, 2026
177
+ order: 'asc',
364
178
  });
365
179
 
366
- // Date range
367
- const { records: thisWeek } = await posts.query({
368
- dateRange: {
369
- start: Date.now() - 7 * 24 * 60 * 60 * 1000,
370
- end: Date.now(),
371
- },
180
+ // An entire year
181
+ const { records: yearRecords } = await posts.query({
182
+ timeScope: '_year_26',
183
+ orderBy: 'createdAt',
184
+ order: 'asc',
185
+ limit: 100,
372
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' });
373
198
  ```
374
199
 
375
- **Filter operators:**
200
+ ---
376
201
 
377
- | Operator | Meaning |
378
- |---|---|
379
- | `==` | Equal |
380
- | `!=` | Not equal |
381
- | `>` | Greater than |
382
- | `<` | Less than |
383
- | `>=` | Greater than or equal |
384
- | `<=` | Less than or equal |
385
- | `CONTAINS` | String contains |
202
+ ### Pagination
386
203
 
387
- > ⚠️ You can only filter on fields declared as `queryableFields` when the record was created. Filtering on an un-indexed field returns no results.
204
+ `query()` returns a cursor you can pass straight into the next call.
388
205
 
389
- ### Count Records
206
+ ```ts
207
+ // Page 1
208
+ const page1 = await posts.query({ limit: 20, orderBy: 'createdAt', order: 'desc' });
390
209
 
391
- ```typescript
392
- // Total records in the bucket
393
- const total = await posts.count();
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
+ }
394
219
 
395
- // Records matching filters
396
- const publishedCount = await posts.count([
397
- { field: 'status', op: '==', value: 'published' },
398
- ]);
220
+ // You can also use startAt / endAt for range-based cursor control
221
+ const window = await posts.query({ startAt: cursorA, endAt: cursorB });
399
222
  ```
400
223
 
401
- ### Batch Create
402
-
403
- Up to 500 records per call.
224
+ ---
404
225
 
405
- ```typescript
406
- const created = await posts.batchCreate(
407
- [
408
- { title: 'Post A', status: 'draft' },
409
- { title: 'Post B', status: 'draft' },
410
- { title: 'Post C', status: 'published' },
411
- ],
412
- {
413
- queryableFields: ['status'],
414
- userEmail: 'alice@example.com',
415
- },
416
- );
417
- // → [{ id: '…', title: 'Post A', … }, { id: '', title: 'Post B', … }, …]
226
+ ### Atomic Field Updates
227
+
228
+ Avoid race conditions with server-side sentinels inside `patch()`:
229
+
230
+ ```ts
231
+ 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
241
+ } as any);
418
242
  ```
419
243
 
420
- Each record in the batch can optionally carry a `_customRecordId` for upsert behaviour:
244
+ Enable audit trails and history with extra `patch` options:
421
245
 
422
- ```typescript
423
- await posts.batchCreate([
424
- { _customRecordId: '260601-post_welcome', title: 'Welcome', status: 'published' },
425
- { title: 'Auto-ID post', status: 'draft' },
426
- ]);
246
+ ```ts
247
+ await posts.patch(
248
+ post.id,
249
+ { status: 'published' },
250
+ { userEmail: 'alice@example.com', trackHistory: true },
251
+ );
427
252
  ```
428
253
 
429
- ### Batch Delete
254
+ ---
430
255
 
431
- Up to 500 records per call.
256
+ ### Batch Operations
432
257
 
433
- ```typescript
434
- const { deleted, failed } = await posts.batchDelete([
435
- '260601-rec_01JA',
436
- '260601-rec_02JB',
437
- '260601-rec_03JC',
438
- ]);
258
+ ```ts
259
+ // Create up to 500 records at once
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' },
263
+ );
439
264
 
440
- console.log(`Deleted: ${deleted}, Failed: ${failed.length}`);
265
+ // Update up to 500 records at once
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
+ );
273
+
274
+ // Delete up to 500 records at once
275
+ const { successful, failed } = await posts.batchDelete(['id1', 'id2']);
441
276
  ```
442
277
 
443
- ### Version History
278
+ Batch upsert using custom IDs — include `_customRecordId` on each item:
444
279
 
445
- Every write creates a new version — you can restore any record to any previous state.
280
+ ```ts
281
+ await posts.batchCreate([
282
+ { _customRecordId: '260601-post_hello', title: 'Hello' },
283
+ { _customRecordId: '260601-post_world', title: 'World' },
284
+ ] as any);
285
+ ```
446
286
 
447
- ```typescript
448
- // Get the full history list (most recent first)
449
- const history = await posts.getHistory('260601-rec_01JA2XYZ');
450
- // [{ id, version: 3, createdAt, data }, { version: 2, … }, { version: 1, … }]
287
+ ---
451
288
 
452
- // Restore to version 1 (the original)
453
- const restored = await posts.restoreVersion('260601-rec_01JA2XYZ', history[2]!.version);
454
- ```
289
+ ### Version History
455
290
 
456
- ### Write-Filter Sentinels
291
+ Every write is automatically versioned when `trackHistory` is enabled.
457
292
 
458
- For atomic server-side field operations — use these inside `patch()` to avoid race conditions.
293
+ ```ts
294
+ // List all saved versions
295
+ const history = await posts.getHistory(post.id);
296
+ // → [{ generation, savedAt, savedBy, sizeBytes }, …]
459
297
 
460
- ```typescript
461
- await posts.patch('260601-rec_01JA', {
462
- // Increment / decrement a numeric field atomically
463
- views: { __op: 'increment', delta: 1 },
464
- credits: { __op: 'decrement', delta: 5 },
298
+ // Retrieve a specific past version
299
+ const v1 = await posts.getVersion(post.id, history[0].generation!);
300
+ ```
465
301
 
466
- // Set a field only if it doesn't already have a value
467
- slug: { __op: 'setOnce', value: 'my-first-post' },
302
+ ---
468
303
 
469
- // Set a field only if a condition is met
470
- discount: { __op: 'setIf', value: 10, cond: { op: '>=', value: 100 } },
304
+ ### Custom Record IDs
471
305
 
472
- // Add to an array (no duplicates)
473
- tags: { __op: 'appendUnique', item: 'featured' },
306
+ Supply your own ID at creation time — if it already exists the record is upserted in-place.
474
307
 
475
- // Remove from an array
476
- tags: { __op: 'removeFromArray', item: 'draft' },
308
+ ```ts
309
+ // Format: YYMMDD-segment1__segment2
310
+ const post = await posts.create(
311
+ { title: 'Welcome' },
312
+ { customRecordId: '260601-post_welcome' },
313
+ );
314
+ ```
477
315
 
478
- // Clamp a numeric value between min and max
479
- rating: { __op: 'clamp', value: 6, min: 0, max: 5 },
316
+ ---
480
317
 
481
- // Multiply a numeric field
482
- price: { __op: 'multiplyBy', factor: 1.1 },
318
+ ### Existence Check
483
319
 
484
- // Flip a boolean
485
- active: { __op: 'toggleBool' },
320
+ A lightweight HEAD request — much cheaper than fetching the full record:
486
321
 
487
- // Set field to the server's current timestamp
488
- lastSeen: { __op: 'serverTimestamp' },
489
- } as any);
322
+ ```ts
323
+ const exists = await posts.exists(post.id); // true | false
490
324
  ```
491
325
 
492
- ### Custom Record IDs
326
+ ---
493
327
 
494
- Provide your own ID instead of using an auto-generated one. If the ID already exists, the record is upserted.
328
+ ### Get All Records
495
329
 
496
- ```typescript
497
- // Single record
498
- const post = await posts.create(
499
- { title: 'Welcome', status: 'published' },
500
- { customRecordId: '260601-post_welcome' },
501
- );
330
+ Fetches every record matching the options without filter support. Use `query()` when you need filters.
502
331
 
503
- // Batch — set _customRecordId on individual items
504
- await posts.batchCreate([
505
- { _customRecordId: '260601-post_welcome', title: 'Welcome' },
506
- { title: 'Auto-ID post' },
507
- ]);
332
+ ```ts
333
+ const all = await posts.getAll({
334
+ orderBy: 'createdAt',
335
+ order: 'desc',
336
+ limit: 500,
337
+ });
508
338
  ```
509
339
 
510
- Custom IDs must match `^[a-zA-Z_][a-zA-Z0-9_.\-]{0,200}$`.
511
-
512
340
  ---
513
341
 
514
- ## Authentication
342
+ ## Auth
515
343
 
516
- HydrousDB has a complete user auth system. Your users live in a bucket you name (e.g. `"app-users"`). You get sessions, refresh tokens, password reset, email verification, and admin controls — all built in.
344
+ A complete user system signup, login, sessions, password reset, email verification, and admin controls.
517
345
 
518
- ```typescript
519
- const auth = db.auth('app-users');
346
+ ```ts
347
+ const auth = db.auth();
520
348
  ```
521
349
 
522
- ### Sign Up
350
+ ### Sign Up & Log In
523
351
 
524
- ```typescript
352
+ ```ts
353
+ // Sign up — extra fields beyond email/password are stored on the user
525
354
  const { user, session } = await auth.signup({
526
355
  email: 'alice@example.com',
527
- password: 'hunter2', // validated server-side
528
- fullName: 'Alice Wonderland',
529
- // Any extra fields are stored on the user record:
530
- plan: 'pro',
531
- referral: 'friend123',
356
+ password: 'hunter2',
357
+ fullName: 'Alice Smith',
358
+ plan: 'pro', // any custom fields you want
532
359
  });
533
360
 
534
- // Persist these in your app / session store:
535
- // session.sessionId
536
- // session.refreshToken
537
- // session.expiresAt
538
-
539
- console.log(user.id); // "usr_xxxxxxxxxxxx"
540
- console.log(user.emailVerified); // false — send a verification email
541
- ```
542
-
543
- ### Log In / Log Out
544
-
545
- ```typescript
546
361
  // Log in
547
362
  const { user, session } = await auth.login({
548
363
  email: 'alice@example.com',
549
364
  password: 'hunter2',
550
365
  });
551
366
 
552
- // Log out invalidates the session server-side
367
+ // Log out this device
553
368
  await auth.logout({ sessionId: session.sessionId });
554
369
 
555
- // Log out from all devices at once
370
+ // Log out everywhere
556
371
  await auth.logout({ sessionId: session.sessionId, allDevices: true });
557
372
  ```
558
373
 
559
- ### Session Management
374
+ Store `session.sessionId` and `session.refreshToken` in your app.
560
375
 
561
- Sessions expire after **24 hours**. Refresh tokens last **30 days**.
376
+ | Token | Lifetime |
377
+ |---|---|
378
+ | `sessionId` | 24 hours |
379
+ | `refreshToken` | 30 days |
562
380
 
563
- ```typescript
564
- // Refresh before expiry to get a new session
565
- const newSession = await auth.refreshSession({
566
- refreshToken: session.refreshToken,
567
- });
568
- // Store newSession.sessionId and newSession.refreshToken
381
+ ---
569
382
 
570
- // Get a user by ID
571
- const user = await auth.getUser({ userId: session.userId });
572
- ```
383
+ ### Session Management
573
384
 
574
- ### Validate a Session
385
+ ```ts
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 }
575
389
 
576
- Use this on your backend to verify an incoming session is still active.
390
+ // Get a brand-new session from a refresh token (before the old one expires)
391
+ const newSession = await auth.refreshSession(session.refreshToken);
392
+ ```
577
393
 
578
- ```typescript
579
- const { user, session: activeSession } = await auth.validateSession({
580
- sessionId: session.sessionId,
581
- });
394
+ ---
582
395
 
583
- console.log(user.id); // "usr_xxxxxxxxxxxx"
584
- console.log(activeSession.expiresAt); // timestamp
585
- ```
396
+ ### User Profile
586
397
 
587
- ### Update Profile
398
+ ```ts
399
+ // Fetch a user by ID
400
+ const user = await auth.getUser(session.userId);
588
401
 
589
- ```typescript
590
- const updated = await auth.updateUser({
402
+ // Update profile fields (users can update themselves; admins can update anyone)
403
+ await auth.updateUser({
591
404
  sessionId: session.sessionId,
592
405
  userId: user.id,
593
- updates: {
594
- fullName: 'Alice Smith',
595
- plan: 'enterprise',
596
- // Any field on the user record can be updated here
597
- avatar: 'https://example.com/avatar.jpg',
598
- },
406
+ updates: { fullName: 'Alice Johnson', plan: 'enterprise' },
599
407
  });
408
+
409
+ // Soft-delete (users can delete themselves; admins can delete anyone)
410
+ await auth.deleteUser(session.sessionId, user.id);
600
411
  ```
601
412
 
602
- > ⚠️ The `updates` key is required — it wraps the fields to change. Fields not included in `updates` are left untouched.
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
+ ```
603
429
 
604
- ### Change Password
430
+ ---
605
431
 
606
- Requires an active session — so a stolen old password alone is not enough.
432
+ ### Password & Email
607
433
 
608
- ```typescript
434
+ ```ts
435
+ // Change password — requires an active session AND the current password
609
436
  await auth.changePassword({
610
437
  sessionId: session.sessionId,
611
438
  userId: user.id,
612
439
  currentPassword: 'hunter2',
613
440
  newPassword: 'correcthorsebatterystaple',
614
441
  });
615
- // All existing sessions for this user are automatically revoked
616
- ```
617
-
618
- ### Password Reset Flow
619
442
 
620
- ```typescript
621
- // 1. User requests a reset — always returns success (prevents email enumeration)
622
- await auth.requestPasswordReset({ email: 'alice@example.com' });
443
+ // Forgot-password flow
444
+ await auth.requestPasswordReset('alice@example.com'); // always succeeds (prevents enumeration)
445
+ await auth.confirmPasswordReset(tokenFromEmail, 'newpassword123');
623
446
 
624
- // 2. User receives the reset token via email (handled by your email provider)
625
-
626
- // 3. User submits the token + new password
627
- await auth.confirmPasswordReset({
628
- resetToken: 'tok_from_email',
629
- newPassword: 'correcthorsebatterystaple',
630
- });
631
- // All existing sessions are automatically revoked
447
+ // Email verification
448
+ await auth.requestEmailVerification(user.id);
449
+ await auth.confirmEmailVerification(tokenFromEmail);
632
450
  ```
633
451
 
634
- ### Email Verification
635
-
636
- ```typescript
637
- // 1. Send the verification email
638
- await auth.requestEmailVerification({ userId: user.id });
639
-
640
- // 2. User clicks link in their inbox — your app extracts the token from the URL
641
-
642
- // 3. Confirm the token
643
- await auth.confirmEmailVerification({ verifyToken: 'tok_from_email' });
644
- ```
452
+ ---
645
453
 
646
- ### Admin — List Users
454
+ ### Admin Controls
647
455
 
648
- Admin operations require a valid session from a user with `role: 'admin'`.
456
+ All admin methods require an active admin session.
649
457
 
650
- ```typescript
651
- // Paginated list — uses cursor-based pagination
458
+ ```ts
459
+ // Paginated user list
652
460
  const { users, hasMore, nextCursor } = await auth.listUsers({
653
461
  sessionId: adminSession.sessionId,
654
462
  limit: 50,
463
+ cursor: previousNextCursor, // optional — for subsequent pages
655
464
  });
656
465
 
657
- if (hasMore) {
658
- const page2 = await auth.listUsers({
659
- sessionId: adminSession.sessionId,
660
- limit: 50,
661
- cursor: nextCursor!,
662
- });
663
- }
664
- ```
665
-
666
- Each user in the list includes:
667
-
668
- ```typescript
669
- {
670
- id: 'usr_xxxxxxxxxxxx',
671
- email: 'alice@example.com',
672
- fullName: 'Alice Wonderland',
673
- emailVerified: true,
674
- accountStatus: 'active', // 'active' | 'locked' | 'suspended'
675
- role: 'user', // 'user' | 'admin'
676
- createdAt: 1717200000000,
677
- updatedAt: 1717200000000,
678
- // ...any extra fields stored at signup
679
- }
680
- ```
681
-
682
- ### Admin — Lock / Unlock
683
-
684
- ```typescript
685
- // Lock an account (prevents login)
466
+ // Lock an account (default: 15 minutes)
686
467
  const { lockedUntil, unlockTime } = await auth.lockAccount({
687
468
  sessionId: adminSession.sessionId,
688
- userId: 'usr_abc123',
689
- duration: 60 * 60 * 1000, // 1 hour in ms (default: 15 minutes)
690
- });
691
-
692
- console.log(`Account locked until ${unlockTime}`);
693
-
694
- // Unlock manually
695
- await auth.unlockAccount({
696
- sessionId: adminSession.sessionId,
697
- userId: 'usr_abc123',
469
+ userId: user.id,
470
+ duration: 60 * 60 * 1000, // optional ms lock for 1 hour
698
471
  });
699
- ```
700
472
 
701
- ### Admin Delete Users
473
+ // Unlock an account
474
+ await auth.unlockAccount(adminSession.sessionId, user.id);
702
475
 
703
- ```typescript
704
- // Soft-delete — marks the account as deleted, keeps the data
705
- await auth.deleteUser({
706
- sessionId: adminSession.sessionId,
707
- userId: 'usr_abc123',
708
- });
476
+ // Permanent (hard) delete — cannot be undone
477
+ await auth.hardDeleteUser(adminSession.sessionId, user.id);
709
478
 
710
- // Hard-delete — permanent, irreversible
711
- await auth.hardDeleteUser({
712
- sessionId: adminSession.sessionId,
713
- userId: 'usr_abc123',
714
- });
715
-
716
- // Bulk delete — up to 500 users, soft or hard
479
+ // Bulk delete — soft or hard
717
480
  const { succeeded, failed } = await auth.bulkDeleteUsers({
718
481
  sessionId: adminSession.sessionId,
719
- userIds: ['usr_a', 'usr_b', 'usr_c'],
720
- hard: false, // set true for permanent deletion
482
+ userIds: ['id1', 'id2', 'id3'],
483
+ hard: true, // optional defaults to soft delete
721
484
  });
722
485
  ```
723
486
 
@@ -725,323 +488,279 @@ const { succeeded, failed } = await auth.bulkDeleteUsers({
725
488
 
726
489
  ## File Storage
727
490
 
728
- HydrousDB Storage is backed by Google Cloud Storage. Storage keys (`ssk_…`) are scoped per bucket — you can give different parts of your app different permissions.
491
+ Files are **private by default** and scoped to your storage key server-side.
729
492
 
730
- ```typescript
731
- const main = db.storage('main');
732
- const avatars = db.storage('avatars');
733
- const documents = db.storage('documents');
493
+ ```ts
494
+ const storage = db.storage('main'); // 'main' matches a key in storageKeys config
734
495
  ```
735
496
 
736
- ### Simple Upload
497
+ ### Upload
737
498
 
738
- Server-buffered upload for files up to **500 MB**. No progress bar — use [Large File Upload](#large-file-upload-with-progress) if you need one.
739
-
740
- ```typescript
741
- // Browser from a file input
742
- const file = document.querySelector('input[type="file"]').files[0];
743
- const result = await db.storage('main').upload(file, `uploads/${file.name}`, {
744
- isPublic: true, // publicly accessible without auth (default: false)
745
- overwrite: false, // throw if file already exists (default: false)
746
- mimeType: 'image/jpeg', // optional — auto-detected from extension if omitted
499
+ ```ts
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
747
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);
748
512
 
749
- console.log(result.publicUrl); // CDN URL use anywhere
750
- console.log(result.downloadUrl); // null when isPublic: true
751
- console.log(result.size); // bytes
752
- console.log(result.mimeType); // 'image/jpeg'
753
-
754
- // Node.js — from a Buffer or file path
755
- import { readFileSync } from 'fs';
756
- const buf = readFileSync('./report.pdf');
757
- const result = await db.storage('documents').upload(buf, 'reports/q3.pdf');
758
- console.log(result.downloadUrl); // auth-required download URL
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' });
759
516
  ```
760
517
 
761
- ### Upload Raw JSON or Text
762
-
763
- ```typescript
764
- // Upload a JS object as JSON
765
- const result = await db.storage('main').uploadRaw(
766
- { theme: 'dark', language: 'en', version: 3 },
767
- 'settings/alice.json',
768
- { isPublic: false },
769
- );
770
-
771
- // Upload a plain string
772
- await db.storage('main').uploadRaw(
773
- '<html><body>Hello</body></html>',
774
- 'exports/page.html',
775
- { mimeType: 'text/html', isPublic: true },
776
- );
777
- ```
778
-
779
- ### Large File Upload with Progress
518
+ ---
780
519
 
781
- For files over ~10 MB or when you need a progress bar. The file goes **directly to GCS** — your server never buffers the bytes.
520
+ ### Large Files with Progress
782
521
 
783
- ```typescript
784
- const storage = db.storage('main');
522
+ Recommended for files > 10 MB or when you need a progress indicator (browsers only).
785
523
 
786
- // Step 1 — Get a signed GCS upload URL
787
- const { uploadUrl, path } = await storage.getUploadUrl({
788
- path: 'videos/product-demo.mp4',
789
- mimeType: 'video/mp4',
790
- size: file.size,
791
- isPublic: true,
792
- expiresInSeconds: 900, // how long the signed URL is valid (default: 900 = 15 min)
524
+ ```ts
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
793
533
  });
794
534
 
795
- // Step 2 — Upload directly to GCS with real progress
796
- await storage.uploadToSignedUrl(
797
- uploadUrl,
798
- file,
799
- 'video/mp4',
800
- (percent) => {
801
- progressBar.style.width = `${percent}%`;
802
- console.log(`${percent}% uploaded`);
803
- },
804
- );
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
+ });
805
539
 
806
- // Step 3 — Confirm the upload (registers metadata on the server)
540
+ // Step 3 — confirm and register metadata server-side
807
541
  const result = await storage.confirmUpload({
808
- path: path,
542
+ path,
809
543
  mimeType: 'video/mp4',
810
544
  isPublic: true,
811
545
  });
812
-
813
- console.log(result.publicUrl); // live CDN URL
814
546
  ```
815
547
 
816
- ### Batch Upload
548
+ ---
817
549
 
818
- Upload up to 50 files at once.
550
+ ### Batch Uploads
819
551
 
820
- ```typescript
821
- const storage = db.storage('main');
552
+ Get signed URLs for up to 50 files at once, upload them in parallel, then confirm in one call.
822
553
 
823
- // Step 1 — Get signed URLs for all files
824
- const { files } = await storage.getBatchUploadUrls([
825
- { path: 'gallery/photo1.jpg', mimeType: 'image/jpeg', size: 204800, isPublic: true },
826
- { path: 'gallery/photo2.jpg', mimeType: 'image/jpeg', size: 153600, isPublic: true },
827
- { path: 'gallery/photo3.png', mimeType: 'image/png', size: 98304, isPublic: true },
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 },
828
559
  ]);
829
560
 
830
- // Step 2 — Upload each directly to GCS
831
- for (const f of files) {
832
- await storage.uploadToSignedUrl(f.uploadUrl, blobs[f.index], f.mimeType);
833
- }
834
-
835
- // Step 3 — Confirm all at once
836
- const { succeeded, failed } = await storage.batchConfirmUploads(
837
- files.map(f => ({ path: f.path, mimeType: f.mimeType, isPublic: true })),
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
+ ),
838
566
  );
839
567
 
840
- console.log(`${succeeded.length} uploaded, ${failed.length} failed`);
841
- for (const f of succeeded) {
842
- console.log(f.publicUrl);
843
- }
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
+ ]);
844
573
  ```
845
574
 
575
+ ---
576
+
846
577
  ### Download
847
578
 
848
- ```typescript
849
- // Private files require authentication returns ArrayBuffer
850
- const buffer = await db.storage('documents').download('reports/q3.pdf');
851
- const blob = new Blob([buffer], { type: 'application/pdf' });
579
+ ```ts
580
+ // Download a private file as ArrayBuffer
581
+ const buffer = await storage.download('private/report.pdf');
852
582
 
853
- // Trigger browser download
583
+ // Convert to a Blob for use in the browser
584
+ const blob = new Blob([buffer], { type: 'application/pdf' });
854
585
  const url = URL.createObjectURL(blob);
855
- const a = document.createElement('a');
856
- a.href = url;
857
- a.download = 'q3.pdf';
858
- a.click();
859
586
  ```
860
587
 
861
- > 💡 **Public files:** Use `result.publicUrl` directly — no SDK call needed. `<img src={result.publicUrl} />` just works.
588
+ For **public** files, just use the `publicUrl` directly — no SDK or authentication needed.
589
+
590
+ ---
862
591
 
863
592
  ### Batch Download
864
593
 
865
- Download up to 20 files in one call. Content is returned as base64 strings.
594
+ Downloads up to 20 files at once. Content is returned as base64-encoded strings.
866
595
 
867
- ```typescript
868
- const { succeeded, failed } = await db.storage('documents').batchDownload([
869
- 'reports/q1.pdf',
870
- 'reports/q2.pdf',
871
- 'reports/q3.pdf',
596
+ ```ts
597
+ const { succeeded, failed } = await storage.batchDownload([
598
+ 'docs/report.pdf',
599
+ 'images/chart.png',
872
600
  ]);
873
601
 
874
- for (const f of succeeded) {
875
- console.log(f.path); // 'reports/q1.pdf'
876
- console.log(f.mimeType); // 'application/pdf'
877
- console.log(f.size); // bytes
878
- const bytes = Buffer.from(f.content, 'base64');
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')
879
605
  }
880
606
 
881
- for (const f of failed) {
882
- console.error(`${f.path}: ${f.error}`);
607
+ for (const err of failed) {
608
+ console.error(err.path, err.error, err.code);
883
609
  }
884
610
  ```
885
611
 
886
- ### List Files
887
-
888
- ```typescript
889
- const storage = db.storage('main');
612
+ ---
890
613
 
891
- // List everything at the root
892
- const { files, folders, hasMore, nextCursor } = await storage.list();
614
+ ### List, Metadata & Signed URLs
893
615
 
894
- // List a specific folder
895
- const result = await storage.list({
896
- prefix: 'gallery/',
897
- limit: 50,
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)
898
623
  });
899
624
 
900
- // Paginate
901
- if (result.hasMore) {
902
- const page2 = await storage.list({
903
- prefix: 'gallery/',
904
- cursor: result.nextCursor,
905
- });
625
+ // Page 2
626
+ if (hasMore) {
627
+ const page2 = await storage.list({ prefix: 'avatars/', cursor: nextCursor });
906
628
  }
907
- ```
908
629
 
909
- Each file entry:
910
-
911
- ```typescript
912
- {
913
- name: 'photo1.jpg',
914
- path: 'gallery/photo1.jpg',
915
- size: 204800,
916
- mimeType: 'image/jpeg',
917
- isPublic: true,
918
- publicUrl: 'https://storage.googleapis.com/…',
919
- downloadUrl: null,
920
- updatedAt: '2025-06-01T12:00:00.000Z',
921
- }
630
+ // Get file metadata
631
+ const meta = await storage.getMetadata('avatars/alice.jpg');
632
+ // → { path, size, mimeType, isPublic, publicUrl, downloadUrl, createdAt, updatedAt }
633
+
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
+ );
922
640
  ```
923
641
 
924
- ### Scoped Storage
642
+ ---
925
643
 
926
- Pre-fix all operations to a folder — great for per-user isolation.
644
+ ### Move, Copy & Delete
927
645
 
928
- ```typescript
929
- const userDocs = db.storage('documents').scope(`users/${userId}/`);
646
+ ```ts
647
+ await storage.move('old/path.jpg', 'new/path.jpg');
648
+ await storage.copy('templates/base.html', 'pages/home.html');
649
+ await storage.deleteFile('avatars/old.jpg');
650
+ await storage.deleteFolder('temp/'); // recursively deletes all contents
651
+ ```
930
652
 
931
- // Uploads to: users/{userId}/contract.pdf
932
- await userDocs.upload(pdfBuffer, 'contract.pdf');
653
+ ---
933
654
 
934
- // Lists: users/{userId}/
935
- const { files } = await userDocs.list();
655
+ ### Visibility
936
656
 
937
- // Deletes: users/{userId}/contract.pdf
938
- await userDocs.deleteFile('contract.pdf');
657
+ ```ts
658
+ // Make a private file public
659
+ const result = await storage.setVisibility('reports/q1.pdf', true);
660
+ console.log(result.publicUrl);
939
661
 
940
- // Nest scopes
941
- const userThumbs = userDocs.scope('thumbnails/');
942
- // All ops under: users/{userId}/thumbnails/
662
+ // Make a public file private
663
+ await storage.setVisibility('reports/q1.pdf', false);
943
664
  ```
944
665
 
945
- `ScopedStorage` has the same full API as `StorageManager` — every method listed in the reference is available.
946
-
947
- ### File Metadata
666
+ ---
948
667
 
949
- ```typescript
950
- const meta = await db.storage('documents').getMetadata('reports/q3.pdf');
668
+ ### Folders
951
669
 
952
- console.log(meta.size); // bytes
953
- console.log(meta.mimeType); // 'application/pdf'
954
- console.log(meta.isPublic); // false
955
- console.log(meta.downloadUrl); // auth-required URL
956
- console.log(meta.createdAt); // ISO string
957
- console.log(meta.updatedAt); // ISO string
670
+ ```ts
671
+ // Create an explicit folder marker (usually not needed — folders are implicit)
672
+ await storage.createFolder('projects/2025/');
958
673
  ```
959
674
 
960
- ### Signed Share URLs
675
+ ---
961
676
 
962
- Generate a time-limited link for a private file — no `X-Storage-Key` required to use it.
677
+ ### Scoped Storage
963
678
 
964
- ```typescript
965
- const { signedUrl, expiresAt, expiresIn } = await db.storage('documents')
966
- .getSignedUrl('reports/q3.pdf', 3600); // expires in 1 hour (default)
679
+ Automatically prefix every path — ideal for per-user file isolation.
967
680
 
968
- // Share signedUrl with the recipient — it expires automatically
969
- console.log(`Link valid until: ${new Date(expiresAt).toLocaleString()}`);
970
- ```
681
+ ```ts
682
+ const userFiles = db.storage('main').scope(`users/${userId}/`);
971
683
 
972
- > ⚠️ Downloads via signed URLs bypass the server — **download stats are not tracked** for those requests. Use `downloadUrl` for tracked downloads.
684
+ await userFiles.upload(pdf, 'contract.pdf'); // users/{userId}/contract.pdf
685
+ await userFiles.uploadRaw({ key: 'val' }, 'prefs.json');
686
+ const { files } = await userFiles.list(); // → only lists users/{userId}/
973
687
 
974
- ### Visibility
688
+ // Nest deeper
689
+ const reports = userFiles.scope('reports/'); // → users/{userId}/reports/
690
+ await reports.upload(file, 'q1.pdf'); // → users/{userId}/reports/q1.pdf
975
691
 
976
- ```typescript
977
- // Make a private file public
978
- const result = await db.storage('main').setVisibility('docs/report.pdf', true);
979
- console.log(result.publicUrl); // now has a CDN URL
980
-
981
- // Make a public file private
982
- const result2 = await db.storage('main').setVisibility('docs/report.pdf', false);
983
- console.log(result2.downloadUrl); // now requires auth
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/');
984
698
  ```
985
699
 
986
- ### Move, Copy, Delete
700
+ ---
987
701
 
988
- ```typescript
989
- const storage = db.storage('main');
702
+ ### Storage Stats
990
703
 
991
- // Rename a file
992
- await storage.move('drafts/report.pdf', 'published/report-2025.pdf');
704
+ ```ts
705
+ // Stats for this storage key
706
+ const stats = await storage.getStats();
707
+ // → { totalFiles, totalBytes, uploadCount, downloadCount, deleteCount }
993
708
 
994
- // Move to a different folder
995
- await storage.move('inbox/data.csv', 'archive/2025/data.csv');
709
+ // Server info (no auth required)
710
+ const info = await storage.info();
711
+ // → { ok: true, storageRoot: '…' }
712
+ ```
996
713
 
997
- // Copy
998
- await storage.copy('templates/invoice.html', 'invoices/inv-001.html');
714
+ ---
999
715
 
1000
- // Create a folder placeholder
1001
- await storage.createFolder('archive/2025/');
716
+ ## Analytics
1002
717
 
1003
- // Delete a single file
1004
- await storage.deleteFile('temp/scratch.txt');
718
+ BigQuery-powered aggregations. No ETL, no pipelines — just query.
1005
719
 
1006
- // Delete a folder and all its contents recursively
1007
- await storage.deleteFolder('temp/');
720
+ ```ts
721
+ const analytics = db.analytics('orders');
1008
722
  ```
1009
723
 
1010
- ### Storage Stats
724
+ ### Date Range (Time Scope)
1011
725
 
1012
- ```typescript
1013
- const stats = await db.storage('main').getStats();
1014
- // {
1015
- // totalFiles: 842,
1016
- // totalBytes: 1073741824,
1017
- // uploadCount: 1200,
1018
- // downloadCount: 4830,
1019
- // deleteCount: 58,
1020
- // }
1021
-
1022
- // Ping — no auth required
1023
- const { ok, storageRoot } = await db.storage('main').info();
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
+ }
1024
733
  ```
1025
734
 
1026
- ---
735
+ **Granularity options** (for time series methods):
1027
736
 
1028
- ## Analytics
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 |
1029
744
 
1030
- HydrousDB Analytics runs directly against BigQuery on your GCS data — zero ETL, no duplication, live results. Fast even on billions of records.
745
+ **Aggregation options** (for numeric field methods):
1031
746
 
1032
- ```typescript
1033
- const analytics = db.analytics('orders');
1034
- ```
747
+ | Value | Meaning |
748
+ |---|---|
749
+ | `'sum'` | Total |
750
+ | `'avg'` | Average |
751
+ | `'min'` | Minimum |
752
+ | `'max'` | Maximum |
753
+ | `'count'` | Record count |
1035
754
 
1036
- All `dateRange` values are Unix timestamps in milliseconds: `{ start?: number, end?: number }`.
755
+ ---
1037
756
 
1038
757
  ### Count
1039
758
 
1040
- ```typescript
1041
- // Total records
759
+ ```ts
760
+ // All-time total
1042
761
  const { count } = await analytics.count();
1043
762
 
1044
- // In a date range
763
+ // Within a time window
1045
764
  const { count: lastWeek } = await analytics.count({
1046
765
  dateRange: {
1047
766
  start: Date.now() - 7 * 24 * 60 * 60 * 1000,
@@ -1050,283 +769,225 @@ const { count: lastWeek } = await analytics.count({
1050
769
  });
1051
770
  ```
1052
771
 
772
+ ---
773
+
1053
774
  ### Distribution
1054
775
 
1055
- How many records have each unique value for a field?
776
+ How many records have each value of a field.
1056
777
 
1057
- ```typescript
1058
- const rows = await analytics.distribution({
1059
- field: 'status',
1060
- limit: 10,
1061
- order: 'desc', // 'asc' | 'desc'
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() },
1062
784
  });
1063
- // [
1064
- // { value: 'completed', count: 8234 },
1065
- // { value: 'pending', count: 1203 },
1066
- // { value: 'refunded', count: 412 },
1067
- // ]
785
+ // [{ value: 'published', count: 320 }, { value: 'draft', count: 80 }, …]
1068
786
  ```
1069
787
 
788
+ ---
789
+
1070
790
  ### Sum
1071
791
 
1072
- ```typescript
792
+ Sum a numeric field, optionally grouped by another field.
793
+
794
+ ```ts
1073
795
  // Total revenue
1074
- const [{ sum: total }] = await analytics.sum({ field: 'amount' });
796
+ const [{ sum: totalRevenue }] = await analytics.sum({ field: 'amount' });
1075
797
 
1076
- // Revenue by country
1077
- const byCountry = await analytics.sum({
1078
- field: 'amount',
1079
- groupBy: 'country',
1080
- limit: 10,
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
+ },
1081
807
  });
1082
- // [{ group: 'US', sum: 120000 }, { group: 'UK', sum: 45000 }, …]
808
+ // [{ group: 'Europe', sum: 18200 }, { group: 'Americas', sum: 29400 }, …]
1083
809
  ```
1084
810
 
811
+ ---
812
+
1085
813
  ### Time Series
1086
814
 
1087
- Record counts bucketed over time — ideal for activity and growth charts.
815
+ Count of records over time — useful for activity graphs.
1088
816
 
1089
- ```typescript
1090
- const rows = await analytics.timeSeries({
1091
- granularity: 'day', // 'hour' | 'day' | 'week' | 'month' | 'year'
817
+ ```ts
818
+ // Daily record counts, last 30 days
819
+ const daily = await analytics.timeSeries({
820
+ granularity: 'day',
1092
821
  dateRange: {
1093
- start: new Date('2025-01-01').getTime(),
1094
- end: new Date('2025-06-01').getTime(),
822
+ start: Date.now() - 30 * 24 * 60 * 60 * 1000,
823
+ end: Date.now(),
1095
824
  },
1096
825
  });
1097
- // [{ date: '2025-01-01', count: 42 }, { date: '2025-01-02', count: 67 }, …]
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' });
1098
830
  ```
1099
831
 
832
+ ---
833
+
1100
834
  ### Field Time Series
1101
835
 
1102
- Aggregate a numeric field over time.
836
+ Aggregate a numeric field over time — useful for revenue, score, or usage trends.
1103
837
 
1104
- ```typescript
1105
- const revenue = await analytics.fieldTimeSeries({
838
+ ```ts
839
+ // Daily sum of revenue, last 90 days
840
+ const revTrend = await analytics.fieldTimeSeries({
1106
841
  field: 'amount',
1107
- aggregation: 'sum', // 'sum' | 'avg' | 'min' | 'max' | 'count'
1108
- granularity: 'week',
842
+ aggregation: 'sum',
843
+ granularity: 'day',
1109
844
  dateRange: {
1110
- start: new Date('2025-01-01').getTime(),
845
+ start: Date.now() - 90 * 24 * 60 * 60 * 1000,
1111
846
  end: Date.now(),
1112
847
  },
1113
848
  });
1114
- // [{ date: '2025-W01', value: 12340.50 }, { date: '2025-W02', value: 9872.00 }, …]
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
+ });
1115
857
  ```
1116
858
 
859
+ ---
860
+
1117
861
  ### Top N
1118
862
 
1119
- Most common values for a field.
863
+ Most frequent values for a field by record count.
1120
864
 
1121
- ```typescript
1122
- const topProducts = await analytics.topN({
1123
- field: 'productId',
1124
- labelField: 'productName', // optional human-readable label alongside the value
1125
- n: 5,
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
1126
871
  order: 'desc',
872
+ dateRange: { start: new Date('2025-01-01').getTime() },
1127
873
  });
1128
- // [
1129
- // { value: 'prod_123', label: 'Widget Pro', count: 892 },
1130
- // { value: 'prod_456', label: 'Gizmo Plus', count: 743 },
1131
- // ]
874
+ // [{ value: 'US', label: 'United States', count: 420 }, …]
1132
875
  ```
1133
876
 
1134
- ### Field Stats
1135
-
1136
- Statistical summary for any numeric field.
1137
-
1138
- ```typescript
1139
- const stats = await analytics.stats({ field: 'orderValue' });
1140
- // {
1141
- // min: 4.99,
1142
- // max: 9999.99,
1143
- // avg: 87.23,
1144
- // sum: 420948.27,
1145
- // count: 4823,
1146
- // stddev: 143.2,
1147
- // }
1148
- ```
877
+ ---
1149
878
 
1150
- ### Multi-Metric Dashboard
879
+ ### Stats
1151
880
 
1152
- Run several aggregations in a single BigQuery query — one network call.
881
+ Statistical summary for a numeric field.
1153
882
 
1154
- ```typescript
1155
- const dashboard = await analytics.multiMetric({
1156
- metrics: [
1157
- { field: 'amount', name: 'totalRevenue', aggregation: 'sum' },
1158
- { field: 'amount', name: 'avgOrderValue', aggregation: 'avg' },
1159
- { field: 'amount', name: 'maxOrder', aggregation: 'max' },
1160
- { field: 'userId', name: 'uniqueOrders', aggregation: 'count' },
1161
- ],
1162
- dateRange: {
1163
- start: new Date('2025-01-01').getTime(),
1164
- end: Date.now(),
1165
- },
883
+ ```ts
884
+ const priceStats = await analytics.stats({
885
+ field: 'price',
886
+ dateRange: { start: new Date('2025-01-01').getTime() },
1166
887
  });
1167
- // {
1168
- // totalRevenue: 198432.50,
1169
- // avgOrderValue: 87.23,
1170
- // maxOrder: 9999.99,
1171
- // uniqueOrders: 2275,
1172
- // }
888
+ // { min, max, avg, sum, count, stddev }
1173
889
  ```
1174
890
 
1175
- ### Filtered Records via BigQuery
891
+ ---
892
+
893
+ ### Records via BigQuery
1176
894
 
1177
- Fetch raw records using the BigQuery engine useful for large-scale filtered exports.
895
+ Fetch filtered records through the BigQuery engine instead of Firestore. Useful for large result sets or complex server-side filtering.
1178
896
 
1179
- ```typescript
1180
- const records = await analytics.records({
897
+ ```ts
898
+ const records = await analytics.records<Order>({
1181
899
  filters: [
1182
- { field: 'status', op: '==', value: 'refunded' },
1183
- { field: 'amount', op: '>', value: 100 },
900
+ { field: 'status', op: '==', value: 'paid' },
901
+ { field: 'amount', op: '>=', value: 100 },
902
+ { field: 'country', op: 'CONTAINS', value: 'US' },
1184
903
  ],
1185
- selectFields: ['orderId', 'amount', 'userId', 'createdAt'],
1186
- orderBy: 'amount',
904
+ selectFields: ['id', 'amount', 'country', 'createdAt'], // optional projection
905
+ orderBy: 'createdAt',
1187
906
  order: 'desc',
1188
- limit: 50,
907
+ limit: 1000,
1189
908
  offset: 0,
1190
909
  dateRange: {
1191
910
  start: new Date('2025-01-01').getTime(),
1192
- end: Date.now(),
911
+ end: new Date('2025-03-31').getTime(),
1193
912
  },
1194
913
  });
1195
914
  ```
1196
915
 
1197
- ### Cross-Bucket Comparison
916
+ Analytics filter operators: `==` `!=` `>` `<` `>=` `<=` `CONTAINS`
1198
917
 
1199
- Compare the same metric across multiple buckets in one query.
1200
-
1201
- ```typescript
1202
- const comparison = await analytics.crossBucket({
1203
- bucketKeys: ['orders-us', 'orders-eu', 'orders-apac'],
1204
- field: 'amount',
1205
- aggregation: 'sum',
1206
- });
1207
- // [
1208
- // { bucket: 'orders-us', value: 120000 },
1209
- // { bucket: 'orders-eu', value: 45000 },
1210
- // { bucket: 'orders-apac', value: 33000 },
1211
- // ]
1212
- ```
918
+ ---
1213
919
 
1214
- > ⚠️ Your Bucket Security Key must have read access to **every** bucket listed in `bucketKeys`.
920
+ ### Multi-Metric
1215
921
 
1216
- ### Storage Stats
922
+ Compute multiple aggregations in a single round-trip — perfect for dashboards.
1217
923
 
1218
- ```typescript
1219
- const stats = await analytics.storageStats();
1220
- // {
1221
- // totalRecords: 48210,
1222
- // totalBytes: 921600000,
1223
- // avgBytes: 19112,
1224
- // minBytes: 128,
1225
- // maxBytes: 5242880,
1226
- // }
924
+ ```ts
925
+ const dashboard = await analytics.multiMetric({
926
+ metrics: [
927
+ { field: 'amount', name: 'totalRevenue', aggregation: 'sum' },
928
+ { field: 'amount', name: 'avgOrder', aggregation: 'avg' },
929
+ { field: 'amount', name: 'maxOrder', aggregation: 'max' },
930
+ { field: 'recordId', name: 'orderCount', aggregation: 'count' },
931
+ ],
932
+ dateRange: { start: Date.now() - 30 * 24 * 60 * 60 * 1000 },
933
+ });
934
+ // → { totalRevenue: 48200, avgOrder: 96.4, maxOrder: 999, orderCount: 500 }
1227
935
  ```
1228
936
 
1229
- ### Raw Query
937
+ ---
1230
938
 
1231
- Use the `query()` method when none of the typed helpers cover your use case.
939
+ ### Storage Stats (Analytics)
1232
940
 
1233
- ```typescript
1234
- import type { AnalyticsQuery } from 'hydrousdb';
941
+ Record count and byte statistics for the bucket.
1235
942
 
1236
- const result = await analytics.query<{ count: number }>({
1237
- queryType: 'count',
1238
- dateRange: { start: Date.now() - 86400000, end: Date.now() },
943
+ ```ts
944
+ const storageInfo = await analytics.storageStats({
945
+ dateRange: { start: new Date('2025-01-01').getTime() },
1239
946
  });
1240
-
1241
- console.log(result.queryType); // 'count'
1242
- console.log(result.data); // { count: 142 }
947
+ // → { totalRecords, totalBytes, avgBytes, minBytes, maxBytes }
1243
948
  ```
1244
949
 
1245
950
  ---
1246
951
 
1247
- ## TypeScript
1248
-
1249
- The SDK is written entirely in TypeScript and ships full type definitions. Use generics for end-to-end type safety.
952
+ ### Cross-Bucket
1250
953
 
1251
- ```typescript
1252
- import { createClient } from 'hydrousdb';
1253
- import type {
1254
- HydrousConfig,
1255
- RecordResult,
1256
- QueryFilter,
1257
- QueryOptions,
1258
- QueryResult,
1259
- CreateRecordOptions,
1260
- BatchCreateOptions,
1261
- UploadResult,
1262
- UploadUrlResult,
1263
- ListResult,
1264
- FileMetadata,
1265
- SignedUrlResult,
1266
- BatchDownloadResult,
1267
- StorageStats,
1268
- DateRange,
1269
- AnalyticsQuery,
1270
- AnalyticsResult,
1271
- CountResult,
1272
- DistributionRow,
1273
- SumRow,
1274
- TimeSeriesRow,
1275
- FieldTimeSeriesRow,
1276
- TopNRow,
1277
- FieldStats,
1278
- MultiMetricResult,
1279
- StorageStatsResult,
1280
- CrossBucketRow,
1281
- UserRecord,
1282
- Session,
1283
- AuthResult,
1284
- ListUsersResult,
1285
- } from 'hydrousdb';
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.
1286
955
 
1287
- // Define your domain models
1288
- interface Order {
1289
- customerId: string;
1290
- items: Array<{ productId: string; qty: number; price: number }>;
1291
- total: number;
1292
- status: 'pending' | 'processing' | 'completed' | 'refunded';
1293
- country: string;
1294
- }
956
+ ```ts
957
+ const compare = await analytics.crossBucket({
958
+ bucketKeys: ['orders-2024', 'orders-2025'],
959
+ field: 'amount',
960
+ aggregation: 'sum',
961
+ dateRange: { start: new Date('2025-01-01').getTime() },
962
+ });
963
+ // → [{ bucket: 'orders-2024', value: 38200 }, { bucket: 'orders-2025', value: 52100 }]
964
+ ```
1295
965
 
1296
- interface Customer {
1297
- name: string;
1298
- email: string;
1299
- tier: 'free' | 'pro' | 'enterprise';
1300
- credits: number;
1301
- }
966
+ ---
1302
967
 
1303
- const db = createClient({
1304
- authKey: process.env.HYDROUS_AUTH_KEY!,
1305
- bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
1306
- storageKeys: { main: process.env.HYDROUS_STORAGE_MAIN! },
1307
- });
968
+ ### Raw Query
1308
969
 
1309
- // Fully typed autocomplete, compile-time safety throughout
1310
- const orders = db.records<Order>('orders');
1311
- const customers = db.records<Customer>('customers');
970
+ Escape hatch for query types not covered by the typed helpers.
1312
971
 
1313
- const order = await orders.create(
1314
- { customerId: 'cust_abc', items: [{ productId: 'prod_1', qty: 2, price: 29.99 }], total: 59.98, status: 'pending', country: 'US' },
1315
- { queryableFields: ['status', 'country', 'customerId'] },
1316
- );
972
+ ```ts
973
+ import type { AnalyticsQuery, AnalyticsResult } from 'hydrousdb';
1317
974
 
1318
- // TypeScript catches mistakes at compile time:
1319
- // order.nonExistentField ← TS error ✓
1320
- // orders.create({ bad: 1 }) ← TS error ✓
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',
983
+ });
1321
984
  ```
1322
985
 
1323
986
  ---
1324
987
 
1325
988
  ## Error Handling
1326
989
 
1327
- All SDK errors extend `HydrousError`. Specific sub-classes let you handle different failure modes precisely.
1328
-
1329
- ```typescript
990
+ ```ts
1330
991
  import {
1331
992
  HydrousError,
1332
993
  AuthError,
@@ -1338,215 +999,269 @@ import {
1338
999
  } from 'hydrousdb';
1339
1000
 
1340
1001
  try {
1341
- const record = await db.records('orders').get('bad-id');
1002
+ await db.auth().login({ email: 'x@x.com', password: 'wrong' });
1342
1003
  } catch (err) {
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
1013
+ }
1014
+
1343
1015
  if (err instanceof NetworkError) {
1344
- // No internet / server unreachable
1345
- console.error('Cannot reach HydrousDB:', err.message);
1346
-
1347
- } else if (err instanceof AuthError) {
1348
- // Bad key, expired session, insufficient permissions
1349
- console.error(`Auth failed [${err.code}]:`, err.message);
1350
- // err.code: INVALID_CREDENTIALS | ACCOUNT_LOCKED | INVALID_SESSION | FORBIDDEN | …
1351
-
1352
- } else if (err instanceof ValidationError) {
1353
- // Invalid input — check details array
1354
- console.error('Validation failed:', err.details?.join(', '));
1355
-
1356
- } else if (err instanceof RecordError) {
1357
- // Record-specific API error
1358
- console.error(`Record error [${err.code}]:`, err.message);
1359
-
1360
- } else if (err instanceof StorageError) {
1361
- // Storage-specific error
1362
- console.error(`Storage error [${err.code}]:`, err.message);
1363
-
1364
- } else if (err instanceof HydrousError) {
1365
- // Any other API error
1366
- console.error(`API error [${err.code}] ${err.status}:`, err.message);
1367
- console.error('Request ID:', err.requestId); // include in support tickets
1016
+ console.log('No internet or server unreachable');
1017
+ console.log(err.cause); // original error
1368
1018
  }
1369
1019
  }
1370
1020
  ```
1371
1021
 
1372
- **Error properties:**
1022
+ | Error class | When it's thrown |
1023
+ |---|---|
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 |
1027
+ | `StorageError` | Upload/download failures, file not found |
1028
+ | `AnalyticsError` | Invalid query, bucket not found |
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`. |
1373
1031
 
1374
- | Property | Type | Description |
1375
- |---|---|---|
1376
- | `message` | `string` | Human-readable description |
1377
- | `code` | `string` | Machine-readable error code |
1378
- | `status` | `number` | HTTP status code |
1379
- | `requestId` | `string \| undefined` | Server request ID — include in support tickets |
1380
- | `details` | `string[] \| undefined` | Validation error details |
1032
+ ---
1381
1033
 
1382
- **Common error codes:**
1034
+ ## TypeScript
1383
1035
 
1384
- | Code | Status | Meaning |
1385
- |---|---|---|
1386
- | `RECORD_NOT_FOUND` | 404 | The requested record ID does not exist |
1387
- | `INVALID_CREDENTIALS` | 401 | Wrong email or password |
1388
- | `ACCOUNT_LOCKED` | 403 | Account is temporarily locked |
1389
- | `INVALID_SESSION` | 401 | Session expired or revoked — re-authenticate |
1390
- | `MISSING_API_KEY` | 401 | Key not provided in headers |
1391
- | `INVALID_SECURITY_KEY` | 401 | Key is wrong or has been revoked |
1392
- | `FORBIDDEN` | 403 | Insufficient permissions for this operation |
1393
- | `FILE_EXISTS` | 409 | File already exists — use `overwrite: true` |
1394
- | `SYSTEM_BUCKET_FORBIDDEN` | 403 | Cannot query system buckets via analytics |
1395
- | `CROSS_BUCKET_FORBIDDEN` | 403 | Key lacks read access to one of the requested buckets |
1396
- | `VALIDATION_ERROR` | 400 | Invalid input — check `err.details` |
1397
- | `NETWORK_ERROR` | — | Failed to reach the API |
1036
+ The SDK is written in TypeScript. Type your records for full autocomplete and safety:
1398
1037
 
1399
- ---
1038
+ ```ts
1039
+ interface Order {
1040
+ customerId: string;
1041
+ amount: number;
1042
+ status: 'pending' | 'paid' | 'refunded';
1043
+ items: { sku: string; qty: number }[];
1044
+ }
1400
1045
 
1401
- ## Security Best Practices
1046
+ const orders = db.records<Order>('orders');
1402
1047
 
1403
- **1. Never hard-code keys — use environment variables.**
1048
+ const order = await orders.create({
1049
+ customerId: 'cust_123',
1050
+ amount: 49.99,
1051
+ status: 'pending',
1052
+ items: [{ sku: 'SHOE-42', qty: 1 }],
1053
+ });
1054
+ // order.status → 'pending' | 'paid' | 'refunded' ✓
1055
+ // order.id, order.createdAt, order.updatedAt are automatically added ✓
1404
1056
 
1405
- ```bash
1406
- # .env (add .env to your .gitignore)
1407
- HYDROUS_AUTH_KEY=hk_auth_xxxxxxxxxxxxxxxxxxxx
1408
- HYDROUS_BUCKET_KEY=hk_bucket_xxxxxxxxxxxxxxxxxxxx
1409
- HYDROUS_STORAGE_MAIN=ssk_xxxxxxxxxxxxxxxxxxxx
1057
+ const result = await orders.query({
1058
+ filters: [{ field: 'status', op: '==', value: 'paid' }],
1059
+ });
1060
+ // result.records → (Order & RecordResult)[] ✓
1410
1061
  ```
1411
1062
 
1412
- ```typescript
1413
- const db = createClient({
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';
1076
+ ```
1077
+
1078
+ ---
1079
+
1080
+ ## Security
1081
+
1082
+ - **Never commit API keys.** Use environment variables (`process.env.…`).
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.
1085
+ - **Files are private by default.** `isPublic` defaults to `false`. Use `getSignedUrl()` for temporary external sharing.
1086
+ - **Use scoped storage** (`storage.scope('prefix/')`) to isolate files per user and prevent path-traversal bugs.
1087
+
1088
+ ---
1089
+
1090
+ ## Framework Examples
1091
+
1092
+ **Next.js (App Router)**
1093
+ ```ts
1094
+ // lib/db.ts
1095
+ import { createClient } from 'hydrousdb';
1096
+ export const db = createClient({
1414
1097
  authKey: process.env.HYDROUS_AUTH_KEY!,
1415
1098
  bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
1416
- storageKeys: { main: process.env.HYDROUS_STORAGE_MAIN! },
1099
+ storageKeys: { main: process.env.HYDROUS_STORAGE_KEY! },
1417
1100
  });
1418
- ```
1419
1101
 
1420
- **2. Never expose keys to browsers.** For browser-side apps, route requests through your own backend, or use per-user sessions from `auth.login()`.
1421
-
1422
- **3. Keys travel in headers — never in URLs.** The SDK enforces this automatically. Keys never appear in server access logs, GCP audit trails, or browser history.
1102
+ // app/api/posts/route.ts
1103
+ import { db } from '@/lib/db';
1104
+ export async function GET() {
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
+ });
1111
+ return Response.json(records);
1112
+ }
1113
+ ```
1423
1114
 
1424
- **4. Rotate keys periodically.** Revoke old keys from the dashboard after rotation.
1115
+ **React (client-side via your own API)**
1116
+ ```ts
1117
+ // Always go through your backend — never put keys in React components
1118
+ const res = await fetch('/api/posts');
1119
+ const data = await res.json();
1120
+ ```
1425
1121
 
1426
- **5. Use scoped storage** (`db.storage('key').scope('prefix/')`) to isolate access per user or feature, reducing the blast radius of any misconfiguration.
1122
+ **Vue / Nuxt**
1123
+ ```ts
1124
+ // composables/useDb.ts
1125
+ import { createClient } from 'hydrousdb';
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
+ });
1131
+ ```
1427
1132
 
1428
- **6. Keep files private by default.** `isPublic` defaults to `false`. Use `getSignedUrl()` for time-limited external sharing rather than making files permanently public.
1133
+ **React Native**
1134
+ ```ts
1135
+ import { createClient } from 'hydrousdb';
1136
+ // Works out of the box — uses the global fetch available in React Native
1137
+ const db = createClient({
1138
+ authKey: HYDROUS_AUTH_KEY,
1139
+ bucketSecurityKey: HYDROUS_BUCKET_KEY,
1140
+ storageKeys: { main: HYDROUS_STORAGE_KEY },
1141
+ });
1142
+ ```
1429
1143
 
1430
1144
  ---
1431
1145
 
1432
- ## Full API Reference
1146
+ ## API Reference
1433
1147
 
1434
- ### `createClient(config)` → `HydrousClient`
1148
+ ### `createClient(config)`
1435
1149
 
1436
- | Config field | Type | Required | Description |
1150
+ | Field | Type | Required | Description |
1437
1151
  |---|---|---|---|
1438
- | `authKey` | `string` | | `hk_auth_…` — used for all auth routes |
1439
- | `bucketSecurityKey` | `string` | | `hk_bucket_…` — used for records & analytics |
1440
- | `storageKeys` | `Record<string, string>` | | Named `ssk_…` keys at least one entry |
1441
- | `baseUrl` | `string` | — | Override the API endpoint (for self-hosting or testing) |
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) |
1442
1156
 
1443
1157
  ---
1444
1158
 
1445
- ### `db.records<T>(bucketKey)` `RecordsClient<T>`
1159
+ ### `db.records<T>(bucket)` all methods
1446
1160
 
1447
- | Method | Signature | Description |
1161
+ | Method | Returns | Description |
1448
1162
  |---|---|---|
1449
- | `create` | `(data: T, opts?: CreateRecordOptions) T & RecordResult` | Create a record. `opts.queryableFields` indexes fields for filtering. `opts.customRecordId` enables upsert. |
1450
- | `get` | `(id: string) → T & RecordResult` | Fetch by ID. Zero index reads. |
1451
- | `set` | `(id: string, data: T) T & RecordResult` | Full replace. |
1452
- | `patch` | `(id: string, data: Partial<T>, opts?) → T & RecordResult` | Partial update. `opts.merge` (default `true`) controls whether missing fields are removed. |
1453
- | `delete` | `(id: string) void` | Permanent delete. |
1454
- | `query` | `(opts?: QueryOptions) → QueryResult<T>` | Filtered, sorted, paginated query. |
1455
- | `getAll` | `(opts?) (T & RecordResult)[]` | Query without filtersconvenience shortcut. |
1456
- | `count` | `(filters?) number` | Count matching records. |
1457
- | `batchCreate` | `(items: T[], opts?: BatchCreateOptions) → (T & RecordResult)[]` | Up to 500 records. |
1458
- | `batchDelete` | `(ids: string[]) { deleted, failed }` | Up to 500 records. |
1459
- | `getHistory` | `(id: string) → RecordHistoryEntry[]` | Full version list, most recent first. |
1460
- | `restoreVersion` | `(id: string, version: number) T & RecordResult` | Roll back to any version. |
1461
-
1462
- **`QueryOptions`:**
1463
-
1464
- ```typescript
1465
- {
1466
- filters?: QueryFilter[]; // [{ field, op, value }, …]
1467
- fields?: string; // comma-separated field names
1468
- orderBy?: string;
1469
- order?: 'asc' | 'desc';
1470
- limit?: number; // default 100, max 1000
1471
- offset?: number;
1472
- startAfter?: string; // cursor for next page
1473
- startAt?: string;
1474
- endAt?: string;
1475
- dateRange?: DateRange; // { start?: number, end?: number }
1476
- }
1477
- ```
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
+
1178
+ | Field | Type | Description |
1179
+ |---|---|---|
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` |
1478
1191
 
1479
1192
  ---
1480
1193
 
1481
- ### `db.auth(bucketKey)` `AuthClient`
1194
+ ### `db.auth()` all methods
1482
1195
 
1483
- | Method | Signature | Description |
1484
- |---|---|---|
1485
- | `signup` | `(opts: SignupOptions) → AuthResult` | Register user + create session. Extra fields beyond `email/password/fullName` are stored on the user record. |
1486
- | `login` | `(opts: LoginOptions) → AuthResult` | Authenticate + create session. |
1487
- | `logout` | `({ sessionId, allDevices? }) → void` | Revoke one or all sessions. |
1488
- | `refreshSession` | `({ refreshToken }) Session` | Extend an expiring session. |
1489
- | `validateSession` | `({ sessionId }) → { user, session }` | Check if a session is still active. |
1490
- | `getUser` | `({ userId }) → UserRecord` | Fetch a user by ID. |
1491
- | `updateUser` | `(opts: UpdateUserOptions) → UserRecord` | Update user fields — must wrap changes in `updates: { … }`. |
1492
- | `changePassword` | `(opts: ChangePasswordOptions) → void` | Authenticated password change. Field is `currentPassword` in the SDK (maps to `oldPassword` on the wire). |
1493
- | `requestPasswordReset` | `({ email }) void` | Trigger reset email. |
1494
- | `confirmPasswordReset` | `({ resetToken, newPassword }) → void` | Apply new password. |
1495
- | `requestEmailVerification` | `({ userId }) → void` | Send verification email. |
1496
- | `confirmEmailVerification` | `({ verifyToken }) void` | Complete verification. |
1497
- | `listUsers` | `(opts: ListUsersOptions) → ListUsersResult` | Paginated user list. Uses cursor-based pagination — `nextCursor` replaces `offset`. |
1498
- | `lockAccount` | `({ sessionId, userId, duration? }) → { lockedUntil, unlockTime }` | Admin only. |
1499
- | `unlockAccount` | `({ sessionId, userId }) → void` | Admin only. |
1500
- | `deleteUser` | `({ sessionId, userId }) → void` | Soft delete. Admin required unless deleting own account. |
1501
- | `hardDeleteUser` | `({ sessionId, userId }) → void` | Permanent. Admin required unless deleting own account. |
1502
- | `bulkDeleteUsers` | `({ sessionId, userIds, hard? }) { succeeded, failed }` | Up to 500 users. Admin only. |
1196
+ | Method | Description |
1197
+ |---|---|
1198
+ | `signup(opts)` | Register + create session. Extra fields on `opts` are stored on the user. |
1199
+ | `login(opts)` | Authenticate + create session. |
1200
+ | `logout({ sessionId, allDevices? })` | Revoke one session or all sessions. |
1201
+ | `validateSession(sessionId)` | Check if a session is active; returns current user. |
1202
+ | `refreshSession(refreshToken)` | Get a new session from a refresh token. |
1203
+ | `getUser(userId)` | Fetch a user by ID. |
1204
+ | `updateUser(opts)` | Update profile fields. |
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. |
1503
1216
 
1504
1217
  ---
1505
1218
 
1506
- ### `db.storage(keyName)` `StorageManager`
1219
+ ### `db.storage(keyName)` all methods
1507
1220
 
1508
- | Method | Signature | Description |
1509
- |---|---|---|
1510
- | `upload` | `(data, path, opts?) → UploadResult` | Server-buffered upload. Up to 500 MB. |
1511
- | `uploadRaw` | `(data, path, opts?) → UploadResult` | Upload a JS object or string as a file. |
1512
- | `getUploadUrl` | `(opts) → UploadUrlResult` | Step 1 of direct upload. `expiresInSeconds` controls URL TTL. |
1513
- | `uploadToSignedUrl` | `(signedUrl, data, mimeType, onProgress?) → void` | Step 2 — upload to GCS directly. `onProgress` callback fires with 0–100. |
1514
- | `confirmUpload` | `(opts) → UploadResult` | Step 3 — register metadata. |
1515
- | `getBatchUploadUrls` | `(files: BatchUploadItem[]) BatchUploadUrlResult` | Up to 50 files. Returns `{ files: [...] }` — only succeeded items included. |
1516
- | `batchConfirmUploads` | `(items) → { succeeded, failed }` | Confirm multiple uploads. Both arrays are returned. |
1517
- | `download` | `(path) → ArrayBuffer` | Download private file. |
1518
- | `batchDownload` | `(paths) → BatchDownloadResult` | Up to 20 files. Returns `{ succeeded, failed }` — content is base64. |
1519
- | `list` | `(opts?) → ListResult` | Returns `{ files, folders, hasMore, nextCursor }`. |
1520
- | `getMetadata` | `(path) → FileMetadata` | Size, MIME type, visibility, URLs. |
1521
- | `getSignedUrl` | `(path, expiresIn?) → SignedUrlResult` | Time-limited share link. Default: 3600s. |
1522
- | `setVisibility` | `(path, isPublic) { path, isPublic, publicUrl, downloadUrl }` | Toggle public/private. |
1523
- | `createFolder` | `(path) → { path }` | Create a GCS prefix placeholder. |
1524
- | `deleteFile` | `(path) → void` | Delete a single file. |
1525
- | `deleteFolder` | `(path) void` | Delete folder + all contents. |
1526
- | `move` | `(from, to) → { from, to }` | Move or rename. |
1527
- | `copy` | `(from, to) { from, to }` | Copy to a new path. |
1528
- | `getStats` | `() → StorageStats` | Key-level totals: files, bytes, upload/download/delete counts. |
1529
- | `info` | `() → { ok, storageRoot }` | Healthcheck — no auth required. |
1530
- | `scope` | `(prefix) → ScopedStorage` | Create a path-prefixed sub-client with the full StorageManager API. |
1221
+ | Method | Description |
1222
+ |---|---|
1223
+ | `upload(data, path, opts?)` | Server-buffered upload (up to 500 MB). |
1224
+ | `uploadRaw(data, path, opts?)` | Upload a JS object or string as a file. |
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.
1531
1246
 
1532
1247
  ---
1533
1248
 
1534
- ### `db.analytics(bucketKey)` `AnalyticsClient`
1249
+ ### `db.analytics(bucket)` all methods
1535
1250
 
1536
- | Method | Signature | Description |
1251
+ | Method | Returns | Description |
1537
1252
  |---|---|---|
1538
- | `count` | `(opts?) CountResult` | Total record count. |
1539
- | `distribution` | `(opts) → DistributionRow[]` | Per-value counts for a field. |
1540
- | `sum` | `(opts) → SumRow[]` | Sum of a numeric field, optional groupBy. |
1541
- | `timeSeries` | `(opts?) TimeSeriesRow[]` | Record counts over time. |
1542
- | `fieldTimeSeries` | `(opts) → FieldTimeSeriesRow[]` | Field aggregation over time. |
1543
- | `topN` | `(opts) → TopNRow[]` | Most frequent values. `labelField` adds a human-readable label. |
1544
- | `stats` | `(opts) → FieldStats` | min / max / avg / sum / count / stddev for a numeric field. |
1545
- | `records` | `(opts?) (T & RecordResult)[]` | Filtered raw records via BigQuery. |
1546
- | `multiMetric` | `(opts) → MultiMetricResult` | Multiple aggregations in one query. Each metric gets a named alias. |
1547
- | `storageStats` | `(opts?) StorageStatsResult` | Record count + byte totals for the bucket. |
1548
- | `crossBucket` | `(opts) → CrossBucketRow[]` | Compare a metric across multiple buckets. |
1549
- | `query` | `(query: AnalyticsQuery) → AnalyticsResult<T>` | Raw query for cases not covered by the typed methods above. |
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`. |
1550
1265
 
1551
1266
  ---
1552
1267
 
@@ -1555,24 +1270,17 @@ const db = createClient({
1555
1270
  ```bash
1556
1271
  git clone https://github.com/hydrousdb/hydrousdb-js.git
1557
1272
  cd hydrousdb-js
1558
-
1559
- npm install # install dependencies
1560
- npm run build # compile ESM + CJS + type declarations
1561
- npm test # run the full test suite (68 tests)
1562
- npm run test:watch # watch mode
1563
- npm run lint # TypeScript type check
1273
+ npm install
1274
+ npm test # run tests
1275
+ npm run build # compile
1564
1276
  ```
1565
1277
 
1566
- ---
1567
-
1568
1278
  ## License
1569
1279
 
1570
- MIT — see [LICENSE](./LICENSE) for details.
1280
+ MIT — [LICENSE](./LICENSE)
1571
1281
 
1572
1282
  ---
1573
1283
 
1574
1284
  <p align="center">
1575
- Built with ❤️ by the <a href="https://hydrousdb.com">HydrousDB</a> team.<br>
1576
- Questions? <a href="mailto:support@hydrousdb.com">support@hydrousdb.com</a> ·
1577
- <a href="https://github.com/hydrousdb/hydrousdb-js/issues">Open an issue</a>
1285
+ Questions? <a href="mailto:support@hydrousdb.com">support@hydrousdb.com</a> · <a href="https://github.com/hydrousdb/hydrousdb-js/issues">Open an issue</a>
1578
1286
  </p>