hydrousdb 3.0.1 → 3.2.0

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,7 +1,8 @@
1
1
  # HydrousDB JavaScript / TypeScript SDK
2
2
 
3
3
  <p align="center">
4
- <strong>The official SDK for <a href="https://hydrousdb.com">HydrousDB</a>records, auth, file storage, and analytics in one package.</strong>
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>
5
6
  </p>
6
7
 
7
8
  <p align="center">
@@ -16,47 +17,65 @@
16
17
  ## Table of Contents
17
18
 
18
19
  - [What is HydrousDB?](#what-is-hydrousdb)
19
- - [Quick Start (5 minutes)](#quick-start-5-minutes)
20
- - [Step 1 — Create your account](#step-1--create-your-account)
21
- - [Step 2 — Create your first bucket](#step-2--create-your-first-bucket)
22
- - [Step 3Grab your Security Key](#step-3--grab-your-security-key)
23
- - [Step 4 — Install the SDK](#step-4--install-the-sdk)
24
- - [Step 5 — Your first record](#step-5--your-first-record)
20
+ - [How It Works](#how-it-works)
21
+ - [Quick Start](#quick-start)
22
+ - [Installation](#installation)
23
+ - [Module FormatsESM & CommonJS](#module-formats--esm--commonjs)
25
24
  - [Records](#records)
26
25
  - [Create](#create-a-record)
27
26
  - [Read](#read-a-record)
28
- - [Update](#update-a-record)
27
+ - [Update — patch vs set](#update-a-record)
29
28
  - [Delete](#delete-a-record)
30
29
  - [Query](#query-records)
31
- - [Batch Operations](#batch-operations)
30
+ - [Count](#count-records)
31
+ - [Batch Create](#batch-create)
32
+ - [Batch Delete](#batch-delete)
32
33
  - [Version History](#version-history)
34
+ - [Write-Filter Sentinels](#write-filter-sentinels)
35
+ - [Custom Record IDs](#custom-record-ids)
33
36
  - [Authentication](#authentication)
34
- - [Sign Up](#sign-up-users)
37
+ - [Sign Up](#sign-up)
35
38
  - [Log In / Log Out](#log-in--log-out)
36
39
  - [Session Management](#session-management)
37
- - [Password Reset](#password-reset-flow)
40
+ - [Validate a Session](#validate-a-session)
41
+ - [Update Profile](#update-profile)
42
+ - [Change Password](#change-password)
43
+ - [Password Reset Flow](#password-reset-flow)
38
44
  - [Email Verification](#email-verification)
39
- - [Admin Operations](#admin-operations)
45
+ - [Admin — List Users](#admin--list-users)
46
+ - [Admin — Lock / Unlock](#admin--lock--unlock)
47
+ - [Admin — Delete Users](#admin--delete-users)
40
48
  - [File Storage](#file-storage)
41
49
  - [Simple Upload](#simple-upload)
42
- - [Large File Upload (with progress)](#large-file-upload-with-progress)
43
- - [Download](#download-files)
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)
53
+ - [Download](#download)
54
+ - [Batch Download](#batch-download)
44
55
  - [List Files](#list-files)
45
56
  - [Scoped Storage](#scoped-storage)
46
- - [Share & Visibility](#share--visibility)
47
- - [File Operations](#file-operations)
57
+ - [File Metadata](#file-metadata)
58
+ - [Signed Share URLs](#signed-share-urls)
59
+ - [Visibility](#visibility)
60
+ - [Move, Copy, Delete](#move-copy-delete)
61
+ - [Storage Stats](#storage-stats)
48
62
  - [Analytics](#analytics)
49
- - [Count](#count)
63
+ - [Count](#count-1)
50
64
  - [Distribution](#distribution)
65
+ - [Sum](#sum)
51
66
  - [Time Series](#time-series)
67
+ - [Field Time Series](#field-time-series)
52
68
  - [Top N](#top-n)
53
69
  - [Field Stats](#field-stats)
54
70
  - [Multi-Metric Dashboard](#multi-metric-dashboard)
71
+ - [Filtered Records via BigQuery](#filtered-records-via-bigquery)
55
72
  - [Cross-Bucket Comparison](#cross-bucket-comparison)
56
- - [TypeScript Support](#typescript-support)
73
+ - [Storage Stats](#storage-stats-1)
74
+ - [Raw Query](#raw-query)
75
+ - [TypeScript](#typescript)
57
76
  - [Error Handling](#error-handling)
58
77
  - [Security Best Practices](#security-best-practices)
59
- - [API Reference](#api-reference)
78
+ - [Full API Reference](#full-api-reference)
60
79
  - [Contributing](#contributing)
61
80
  - [License](#license)
62
81
 
@@ -64,193 +83,296 @@
64
83
 
65
84
  ## What is HydrousDB?
66
85
 
67
- HydrousDB is a **backend-as-a-service** platform that gives your app a fully managed backend in minutes. Instead of spinning up servers, databases, and storage buckets yourself, you call an API.
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:**
68
100
 
69
101
  | Feature | What it does |
70
102
  |---|---|
71
- | **Records** | Schemaless JSON document store. Create, read, update, delete and query records in named *buckets*. |
72
- | **Auth** | Full user authentication — signup, login, sessions, password reset, email verification, and admin controls. |
73
- | **Storage** | File uploads and downloads backed by Google Cloud Storage. Public and private files, signed share URLs. |
74
- | **Analytics** | BigQuery-powered aggregations — counts, distributions, time series, top-N, multi-metric dashboards, and cross-bucket comparisons. |
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
+ ---
75
109
 
76
- Everything is organised around two concepts:
110
+ ## How It Works
77
111
 
78
- - **Security Key (`sk_...`)** your master credential. Authenticate all API calls. Keep it secret.
79
- - **Bucket Key** — just the name of your bucket (e.g. `"blog-posts"`, `"app-users"`). Not a secret.
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.
80
113
 
81
- ---
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
+ ```
82
123
 
83
- ## Quick Start (5 minutes)
124
+ Records are gzip-compressed on write (60–80% size reduction). An 850 KB hospital chart becomes ~255 KB on disk, automatically, every time.
84
125
 
85
- ### Step 1 — Create your account
126
+ ---
86
127
 
87
- Go to **[https://hydrousdb.com](https://hydrousdb.com)** and sign up for a free account.
128
+ ## Quick Start
88
129
 
89
- ### Step 2 — Create your first bucket
130
+ ### 1. Create your account
90
131
 
91
- 1. Log in to your dashboard at **[https://hydrousdb.com/dashboard](https://hydrousdb.com/dashboard)**.
92
- 2. Click **"New Bucket"**.
93
- 3. Give it a name — use lowercase letters, numbers, hyphens, or underscores (e.g. `my-first-bucket`).
94
- 4. Click **"Create"**.
132
+ Sign up at [https://hydrousdb.com](https://hydrousdb.com).
95
133
 
96
- > 💡 **What is a bucket?** A bucket is a named collection of JSON records — similar to a table in SQL or a collection in MongoDB.
134
+ ### 2. Get your API keys
97
135
 
98
- ### Step 3 Grab your Security Key
136
+ From the dashboard **Settings API Keys**, create three key types:
99
137
 
100
- 1. In the dashboard, go to **Settings → API Keys**.
101
- 2. Click **"Generate Security Key"**.
102
- 3. Copy the keyit looks like `sk_live_xxxxxxxxxxxxxxxxxxxx`.
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 |
103
143
 
104
- > ⚠️ **Your Security Key is your most important credential.** Treat it like a password. Never commit it to Git. Use environment variables.
144
+ > ⚠️ Never commit these to Git. Store them in environment variables.
105
145
 
106
- ### Step 4 — Install the SDK
146
+ ### 3. Install
107
147
 
108
148
  ```bash
109
149
  npm install hydrousdb
110
- # or
111
- yarn add hydrousdb
112
- # or
113
- pnpm add hydrousdb
150
+ # or: yarn add hydrousdb / pnpm add hydrousdb
114
151
  ```
115
152
 
116
153
  **Requirements:** Node.js 18+ (uses the native `fetch` API).
117
154
 
118
- ### Step 5 Your first record
155
+ ### 4. Create the client and write your first record
119
156
 
120
157
  ```typescript
121
158
  import { createClient } from 'hydrousdb';
122
159
 
123
- // Create the client once — reuse it everywhere
160
+ // Create once — reuse everywhere in your app
124
161
  const db = createClient({
125
- securityKey: process.env.HYDROUS_SECURITY_KEY!, // from Step 3
162
+ authKey: process.env.HYDROUS_AUTH_KEY!,
163
+ bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
164
+ storageKeys: {
165
+ main: process.env.HYDROUS_STORAGE_MAIN!,
166
+ },
126
167
  });
127
168
 
128
- // Write a record to your bucket
129
- const post = await db.records('my-first-bucket').create({
169
+ // Write
170
+ const post = await db.records('my-bucket').create({
130
171
  title: 'Hello, HydrousDB!',
131
- body: 'My first record.',
132
172
  published: false,
133
173
  });
134
174
 
135
- console.log(post.id); // "rec_a1b2c3d4"
175
+ console.log(post.id); // "260601-rec_01JA2XYZ"
136
176
  console.log(post.createdAt); // 1717200000000
137
177
 
138
- // Read it back
139
- const fetched = await db.records('my-first-bucket').get(post.id);
140
- console.log(fetched.title); // "Hello, HydrousDB!"
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
+ ```
187
+
188
+ ---
141
189
 
142
- // Update it
143
- const updated = await db.records('my-first-bucket').patch(post.id, { published: true });
190
+ ## Installation
144
191
 
145
- // Delete it
146
- await db.records('my-first-bucket').delete(post.id);
192
+ ```bash
193
+ npm install hydrousdb
147
194
  ```
148
195
 
149
- 🎉 **That's it!** You're live with zero configuration beyond your Security Key.
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
+ ```
209
+
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
+ ```
150
234
 
151
235
  ---
152
236
 
153
237
  ## Records
154
238
 
155
239
  Records are JSON objects stored in named buckets. Every record automatically gets:
156
- - `id` — unique record identifier (e.g. `"rec_a1b2c3d4"`)
240
+
241
+ - `id` — date-prefixed unique identifier (`"260601-rec_01JA2XYZ"`) — encodes the GCS path
157
242
  - `createdAt` — Unix timestamp in milliseconds
158
- - `updatedAt` — Unix timestamp in milliseconds (updated on every write)
243
+ - `updatedAt` — Unix timestamp in milliseconds
244
+
245
+ ```typescript
246
+ const posts = db.records('blog-posts');
247
+ // or typed:
248
+ const orders = db.records<Order>('orders');
249
+ ```
159
250
 
160
251
  ### Create a Record
161
252
 
162
253
  ```typescript
163
- const products = db.records('products');
164
-
165
- const product = await products.create({
166
- name: 'Wireless Headphones',
167
- price: 79.99,
168
- inStock: true,
169
- tags: ['audio', 'wireless'],
254
+ const post = await posts.create({
255
+ title: 'My First Post',
256
+ body: 'Hello world.',
257
+ status: 'draft',
258
+ views: 0,
170
259
  });
171
260
 
172
- // product.id, product.createdAt, product.updatedAt are added automatically
261
+ // post.id, post.createdAt, post.updatedAt are added automatically
262
+ console.log(post.id); // "260601-rec_01JA2XYZ"
263
+ ```
264
+
265
+ **With queryable fields** — fields you want to filter on server-side must be declared at write time:
266
+
267
+ ```typescript
268
+ const post = await posts.create(
269
+ {
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
277
+ },
278
+ );
173
279
  ```
174
280
 
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.
282
+
175
283
  ### Read a Record
176
284
 
177
285
  ```typescript
178
- // Get by ID
179
- const product = await products.get('rec_abc123');
286
+ // Path computed from the ID in memory — zero index reads
287
+ const post = await posts.get('260601-rec_01JA2XYZ');
180
288
 
181
- // product is null-safe: throws HydrousError with code RECORD_NOT_FOUND if missing
289
+ // Throws HydrousError (code: RECORD_NOT_FOUND) if the ID doesn't exist
182
290
  ```
183
291
 
184
292
  ### Update a Record
185
293
 
294
+ **`patch(id, data)` — merge update.** Only the fields you provide are changed. All other fields on the record are left untouched.
295
+
186
296
  ```typescript
187
- // Patch (merge) only the listed fields are changed
188
- const updated = await products.patch('rec_abc123', {
189
- price: 69.99,
190
- inStock: false,
297
+ const updated = await posts.patch('260601-rec_01JA2XYZ', {
298
+ status: 'published',
299
+ views: 1,
191
300
  });
301
+ ```
302
+
303
+ **`set(id, data)` — full replace.** The entire record is replaced with the new data.
192
304
 
193
- // Set (full replace) — the entire record is replaced
194
- const replaced = await products.set('rec_abc123', {
195
- name: 'Wireless Headphones v2',
196
- price: 89.99,
197
- inStock: true,
198
- tags: ['audio', 'wireless', 'premium'],
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,
199
311
  });
200
312
  ```
201
313
 
314
+ **Disable merge** (force field removal):
315
+
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
+
202
321
  ### Delete a Record
203
322
 
204
323
  ```typescript
205
- await products.delete('rec_abc123');
324
+ await posts.delete('260601-rec_01JA2XYZ');
206
325
  ```
207
326
 
208
327
  ### Query Records
209
328
 
210
329
  ```typescript
211
- // Get all records (up to 100 by default)
212
- const { records } = await products.query();
330
+ // All records (up to 100 by default)
331
+ const { records } = await posts.query();
213
332
 
214
333
  // With filters
215
- const { records: affordableStock } = await products.query({
334
+ const { records: published } = await posts.query({
216
335
  filters: [
217
- { field: 'inStock', op: '==', value: true },
218
- { field: 'price', op: '<', value: 100 },
336
+ { field: 'status', op: '==', value: 'published' },
219
337
  ],
220
338
  });
221
339
 
222
- // Sort and paginate
223
- const { records, hasMore, nextCursor } = await products.query({
224
- orderBy: 'price',
225
- order: 'asc',
340
+ // Multiple filters, sort, limit
341
+ 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',
226
348
  limit: 20,
227
349
  });
228
350
 
229
351
  // Next page
230
352
  if (hasMore) {
231
- const page2 = await products.query({
232
- orderBy: 'price',
233
- order: 'asc',
353
+ const page2 = await posts.query({
354
+ orderBy: 'createdAt',
355
+ order: 'desc',
234
356
  limit: 20,
235
357
  startAfter: nextCursor,
236
358
  });
237
359
  }
238
360
 
239
- // Select only specific fields
240
- const { records: lightRecords } = await products.query({
241
- fields: 'name,price,inStock',
361
+ // Select only specific fields (reduces payload size)
362
+ const { records: light } = await posts.query({
363
+ fields: 'id,title,status,createdAt',
242
364
  });
243
365
 
244
- // Filter by date range
245
- const { records: recent } = await products.query({
366
+ // Date range
367
+ const { records: thisWeek } = await posts.query({
246
368
  dateRange: {
247
- start: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago
369
+ start: Date.now() - 7 * 24 * 60 * 60 * 1000,
248
370
  end: Date.now(),
249
371
  },
250
372
  });
251
373
  ```
252
374
 
253
- **Available filter operators:**
375
+ **Filter operators:**
254
376
 
255
377
  | Operator | Meaning |
256
378
  |---|---|
@@ -260,67 +382,162 @@ const { records: recent } = await products.query({
260
382
  | `<` | Less than |
261
383
  | `>=` | Greater than or equal |
262
384
  | `<=` | Less than or equal |
263
- | `CONTAINS` | String contains (case-sensitive) |
385
+ | `CONTAINS` | String contains |
264
386
 
265
- ### Batch Operations
387
+ > ⚠️ You can only filter on fields declared as `queryableFields` when the record was created. Filtering on an un-indexed field returns no results.
388
+
389
+ ### Count Records
266
390
 
267
391
  ```typescript
268
- // Create multiple records at once
269
- const created = await products.batchCreate([
270
- { name: 'Item A', price: 10.00, inStock: true },
271
- { name: 'Item B', price: 20.00, inStock: false },
272
- { name: 'Item C', price: 30.00, inStock: true },
392
+ // Total records in the bucket
393
+ const total = await posts.count();
394
+
395
+ // Records matching filters
396
+ const publishedCount = await posts.count([
397
+ { field: 'status', op: '==', value: 'published' },
273
398
  ]);
274
- // → [{ id: 'rec_1', ... }, { id: 'rec_2', ... }, { id: 'rec_3', ... }]
399
+ ```
400
+
401
+ ### Batch Create
402
+
403
+ Up to 500 records per call.
404
+
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', … }, …]
418
+ ```
419
+
420
+ Each record in the batch can optionally carry a `_customRecordId` for upsert behaviour:
421
+
422
+ ```typescript
423
+ await posts.batchCreate([
424
+ { _customRecordId: '260601-post_welcome', title: 'Welcome', status: 'published' },
425
+ { title: 'Auto-ID post', status: 'draft' },
426
+ ]);
427
+ ```
275
428
 
276
- // Count records
277
- const total = await products.count();
278
- const inStock = await products.count([{ field: 'inStock', op: '==', value: true }]);
429
+ ### Batch Delete
279
430
 
280
- // Delete multiple records
281
- const { deleted, failed } = await products.batchDelete(['rec_1', 'rec_2', 'rec_3']);
431
+ Up to 500 records per call.
432
+
433
+ ```typescript
434
+ const { deleted, failed } = await posts.batchDelete([
435
+ '260601-rec_01JA',
436
+ '260601-rec_02JB',
437
+ '260601-rec_03JC',
438
+ ]);
439
+
440
+ console.log(`Deleted: ${deleted}, Failed: ${failed.length}`);
282
441
  ```
283
442
 
284
443
  ### Version History
285
444
 
286
- Every write to a record creates a new version stored in GCS, so you can travel back in time.
445
+ Every write creates a new version you can restore any record to any previous state.
446
+
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, … }]
451
+
452
+ // Restore to version 1 (the original)
453
+ const restored = await posts.restoreVersion('260601-rec_01JA2XYZ', history[2]!.version);
454
+ ```
455
+
456
+ ### Write-Filter Sentinels
457
+
458
+ For atomic server-side field operations — use these inside `patch()` to avoid race conditions.
287
459
 
288
460
  ```typescript
289
- // Get the full version history of a record
290
- const history = await products.getHistory('rec_abc123');
291
- // history[0] is the latest version, history[1] is one write before, etc.
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 },
292
465
 
293
- // Restore to a specific version
294
- const restored = await products.restoreVersion('rec_abc123', history[2]!.version);
466
+ // Set a field only if it doesn't already have a value
467
+ slug: { __op: 'setOnce', value: 'my-first-post' },
468
+
469
+ // Set a field only if a condition is met
470
+ discount: { __op: 'setIf', value: 10, cond: { op: '>=', value: 100 } },
471
+
472
+ // Add to an array (no duplicates)
473
+ tags: { __op: 'appendUnique', item: 'featured' },
474
+
475
+ // Remove from an array
476
+ tags: { __op: 'removeFromArray', item: 'draft' },
477
+
478
+ // Clamp a numeric value between min and max
479
+ rating: { __op: 'clamp', value: 6, min: 0, max: 5 },
480
+
481
+ // Multiply a numeric field
482
+ price: { __op: 'multiplyBy', factor: 1.1 },
483
+
484
+ // Flip a boolean
485
+ active: { __op: 'toggleBool' },
486
+
487
+ // Set field to the server's current timestamp
488
+ lastSeen: { __op: 'serverTimestamp' },
489
+ } as any);
295
490
  ```
296
491
 
492
+ ### Custom Record IDs
493
+
494
+ Provide your own ID instead of using an auto-generated one. If the ID already exists, the record is upserted.
495
+
496
+ ```typescript
497
+ // Single record
498
+ const post = await posts.create(
499
+ { title: 'Welcome', status: 'published' },
500
+ { customRecordId: '260601-post_welcome' },
501
+ );
502
+
503
+ // Batch — set _customRecordId on individual items
504
+ await posts.batchCreate([
505
+ { _customRecordId: '260601-post_welcome', title: 'Welcome' },
506
+ { title: 'Auto-ID post' },
507
+ ]);
508
+ ```
509
+
510
+ Custom IDs must match `^[a-zA-Z_][a-zA-Z0-9_.\-]{0,200}$`.
511
+
297
512
  ---
298
513
 
299
514
  ## Authentication
300
515
 
301
- HydrousDB has a built-in user auth system. Your users live in a bucket you create
302
- (e.g. `"app-users"`). You get sessions, refresh tokens, password reset, email
303
- verification, and admin controls out of the box.
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.
304
517
 
305
518
  ```typescript
306
519
  const auth = db.auth('app-users');
307
520
  ```
308
521
 
309
- ### Sign Up Users
522
+ ### Sign Up
310
523
 
311
524
  ```typescript
312
525
  const { user, session } = await auth.signup({
313
526
  email: 'alice@example.com',
314
- password: 'hunter2', // min 8 characters, validated server-side
527
+ password: 'hunter2', // validated server-side
315
528
  fullName: 'Alice Wonderland',
316
529
  // Any extra fields are stored on the user record:
317
530
  plan: 'pro',
318
531
  referral: 'friend123',
319
532
  });
320
533
 
321
- // user.id → "usr_xxxxxxxxxx"
322
- // session.sessionId → persist this in your app
323
- // session.refreshToken → persist this for long-lived sessions
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
324
541
  ```
325
542
 
326
543
  ### Log In / Log Out
@@ -332,56 +549,61 @@ const { user, session } = await auth.login({
332
549
  password: 'hunter2',
333
550
  });
334
551
 
335
- // Log out (invalidates the session server-side)
552
+ // Log out invalidates the session server-side
336
553
  await auth.logout({ sessionId: session.sessionId });
554
+
555
+ // Log out from all devices at once
556
+ await auth.logout({ sessionId: session.sessionId, allDevices: true });
337
557
  ```
338
558
 
339
559
  ### Session Management
340
560
 
341
- Sessions expire after **24 hours**. Use the refresh token to get a new session (refresh tokens last **30 days**).
561
+ Sessions expire after **24 hours**. Refresh tokens last **30 days**.
342
562
 
343
563
  ```typescript
344
- // Refresh the session before it expires
564
+ // Refresh before expiry to get a new session
345
565
  const newSession = await auth.refreshSession({
346
566
  refreshToken: session.refreshToken,
347
567
  });
348
568
  // Store newSession.sessionId and newSession.refreshToken
349
569
 
350
- // Get the current user
570
+ // Get a user by ID
351
571
  const user = await auth.getUser({ userId: session.userId });
352
572
  ```
353
573
 
354
- ### Update User Profile
574
+ ### Validate a Session
575
+
576
+ Use this on your backend to verify an incoming session is still active.
577
+
578
+ ```typescript
579
+ const { user, session: activeSession } = await auth.validateSession({
580
+ sessionId: session.sessionId,
581
+ });
582
+
583
+ console.log(user.id); // "usr_xxxxxxxxxxxx"
584
+ console.log(activeSession.expiresAt); // timestamp
585
+ ```
586
+
587
+ ### Update Profile
355
588
 
356
589
  ```typescript
357
590
  const updated = await auth.updateUser({
358
591
  sessionId: session.sessionId,
359
592
  userId: user.id,
360
- data: {
593
+ updates: {
361
594
  fullName: 'Alice Smith',
362
595
  plan: 'enterprise',
596
+ // Any field on the user record can be updated here
363
597
  avatar: 'https://example.com/avatar.jpg',
364
598
  },
365
599
  });
366
600
  ```
367
601
 
368
- ### Password Reset Flow
602
+ > ⚠️ The `updates` key is required — it wraps the fields to change. Fields not included in `updates` are left untouched.
369
603
 
370
- ```typescript
371
- // 1. User requests a reset (always returns success — prevents email enumeration)
372
- await auth.requestPasswordReset({ email: 'alice@example.com' });
604
+ ### Change Password
373
605
 
374
- // 2. User receives an email with a reset token (your app handles the email sending)
375
-
376
- // 3. User submits the new password
377
- await auth.confirmPasswordReset({
378
- resetToken: 'tok_from_email',
379
- newPassword: 'correcthorsebatterystaple',
380
- });
381
- // All existing sessions for this user are automatically revoked
382
- ```
383
-
384
- ### Change Password (authenticated)
606
+ Requires an active session so a stolen old password alone is not enough.
385
607
 
386
608
  ```typescript
387
609
  await auth.changePassword({
@@ -390,61 +612,112 @@ await auth.changePassword({
390
612
  currentPassword: 'hunter2',
391
613
  newPassword: 'correcthorsebatterystaple',
392
614
  });
615
+ // All existing sessions for this user are automatically revoked
616
+ ```
617
+
618
+ ### Password Reset Flow
619
+
620
+ ```typescript
621
+ // 1. User requests a reset — always returns success (prevents email enumeration)
622
+ await auth.requestPasswordReset({ email: 'alice@example.com' });
623
+
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
393
632
  ```
394
633
 
395
634
  ### Email Verification
396
635
 
397
636
  ```typescript
398
- // 1. Send verification email
637
+ // 1. Send the verification email
399
638
  await auth.requestEmailVerification({ userId: user.id });
400
639
 
401
- // 2. User clicks link in email, your app extracts the token
640
+ // 2. User clicks link in their inbox — your app extracts the token from the URL
402
641
 
403
642
  // 3. Confirm the token
404
643
  await auth.confirmEmailVerification({ verifyToken: 'tok_from_email' });
405
644
  ```
406
645
 
407
- ### Admin Operations
646
+ ### Admin — List Users
408
647
 
409
648
  Admin operations require a valid session from a user with `role: 'admin'`.
410
649
 
411
650
  ```typescript
412
- // List all users
413
- const { users, total } = await auth.listUsers({
651
+ // Paginated list — uses cursor-based pagination
652
+ const { users, hasMore, nextCursor } = await auth.listUsers({
414
653
  sessionId: adminSession.sessionId,
415
654
  limit: 50,
416
- offset: 0,
417
655
  });
418
656
 
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
419
685
  // Lock an account (prevents login)
420
- await auth.lockAccount({
686
+ const { lockedUntil, unlockTime } = await auth.lockAccount({
421
687
  sessionId: adminSession.sessionId,
422
688
  userId: 'usr_abc123',
423
- duration: 60 * 60 * 1000, // lock for 1 hour (default: 15 minutes)
689
+ duration: 60 * 60 * 1000, // 1 hour in ms (default: 15 minutes)
424
690
  });
425
691
 
426
- // Unlock an account
692
+ console.log(`Account locked until ${unlockTime}`);
693
+
694
+ // Unlock manually
427
695
  await auth.unlockAccount({
428
696
  sessionId: adminSession.sessionId,
429
697
  userId: 'usr_abc123',
430
698
  });
699
+ ```
431
700
 
432
- // Soft-delete a user (marks as deleted, keeps data)
701
+ ### Admin Delete Users
702
+
703
+ ```typescript
704
+ // Soft-delete — marks the account as deleted, keeps the data
433
705
  await auth.deleteUser({
434
706
  sessionId: adminSession.sessionId,
435
707
  userId: 'usr_abc123',
436
708
  });
437
709
 
438
- // Hard-delete a user (permanent irreversible)
710
+ // Hard-delete permanent, irreversible
439
711
  await auth.hardDeleteUser({
440
712
  sessionId: adminSession.sessionId,
441
713
  userId: 'usr_abc123',
442
714
  });
443
715
 
444
- // Bulk delete multiple users
445
- const { deleted, failed } = await auth.bulkDeleteUsers({
716
+ // Bulk delete up to 500 users, soft or hard
717
+ const { succeeded, failed } = await auth.bulkDeleteUsers({
446
718
  sessionId: adminSession.sessionId,
447
719
  userIds: ['usr_a', 'usr_b', 'usr_c'],
720
+ hard: false, // set true for permanent deletion
448
721
  });
449
722
  ```
450
723
 
@@ -452,64 +725,75 @@ const { deleted, failed } = await auth.bulkDeleteUsers({
452
725
 
453
726
  ## File Storage
454
727
 
455
- HydrousDB Storage is backed by Google Cloud Storage. Your files live at:
456
- ```
457
- hydrous-storage/{your-owner-id}/{your-path}
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.
729
+
730
+ ```typescript
731
+ const main = db.storage('main');
732
+ const avatars = db.storage('avatars');
733
+ const documents = db.storage('documents');
458
734
  ```
459
- You never see or specify the owner prefix — the SDK handles it transparently.
460
735
 
461
736
  ### Simple Upload
462
737
 
463
- For files up to **500 MB** when you don't need upload progress:
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.
464
739
 
465
740
  ```typescript
466
- // Browser: upload from a file input
467
- const fileInput = document.querySelector('input[type="file"]');
468
- const file = fileInput.files[0];
469
-
470
- const result = await db.storage.upload(file, `uploads/${file.name}`, {
471
- isPublic: true, // publicly accessible without auth
472
- overwrite: false, // throw if the file already exists
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
473
747
  });
474
748
 
475
- console.log(result.publicUrl); // CDN URL — usable anywhere
476
- console.log(result.downloadUrl); // null (it's public)
749
+ console.log(result.publicUrl); // CDN URL — use anywhere
750
+ console.log(result.downloadUrl); // null when isPublic: true
477
751
  console.log(result.size); // bytes
478
- console.log(result.mimeType); // auto-detected from extension
752
+ console.log(result.mimeType); // 'image/jpeg'
479
753
 
480
- // Node.js: upload from a Buffer
754
+ // Node.js from a Buffer or file path
481
755
  import { readFileSync } from 'fs';
482
- const buffer = readFileSync('./report.pdf');
483
- const result = await db.storage.upload(buffer, 'reports/q3.pdf');
484
- console.log(result.downloadUrl); // requires X-Storage-Key to access
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
485
759
  ```
486
760
 
487
761
  ### Upload Raw JSON or Text
488
762
 
489
763
  ```typescript
490
- const result = await db.storage.uploadRaw(
491
- { theme: 'dark', language: 'en' },
492
- 'user-config/alice.json',
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',
493
768
  { isPublic: false },
494
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
+ );
495
777
  ```
496
778
 
497
- ### Large File Upload (with progress)
779
+ ### Large File Upload with Progress
498
780
 
499
- For files over 10 MB or when you need a progress bar. The file goes directly
500
- to GCS — your server never buffers it.
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.
501
782
 
502
783
  ```typescript
503
- // Step 1: Get a signed upload URL
504
- const { uploadUrl, path } = await db.storage.getUploadUrl({
505
- path: 'videos/product-demo.mp4',
506
- mimeType: 'video/mp4',
507
- size: file.size,
508
- isPublic: true,
784
+ const storage = db.storage('main');
785
+
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)
509
793
  });
510
794
 
511
- // Step 2: Upload directly to GCS with progress
512
- await db.storage.uploadToSignedUrl(
795
+ // Step 2 Upload directly to GCS with real progress
796
+ await storage.uploadToSignedUrl(
513
797
  uploadUrl,
514
798
  file,
515
799
  'video/mp4',
@@ -519,74 +803,111 @@ await db.storage.uploadToSignedUrl(
519
803
  },
520
804
  );
521
805
 
522
- // Step 3: Confirm the upload (registers metadata)
523
- const result = await db.storage.confirmUpload({
806
+ // Step 3 Confirm the upload (registers metadata on the server)
807
+ const result = await storage.confirmUpload({
524
808
  path: path,
525
809
  mimeType: 'video/mp4',
526
810
  isPublic: true,
527
811
  });
528
812
 
529
- console.log(result.publicUrl); // ready to use
813
+ console.log(result.publicUrl); // live CDN URL
530
814
  ```
531
815
 
532
816
  ### Batch Upload
533
817
 
818
+ Upload up to 50 files at once.
819
+
534
820
  ```typescript
535
- // Get signed URLs for multiple files
536
- const { files } = await db.storage.getBatchUploadUrls([
821
+ const storage = db.storage('main');
822
+
823
+ // Step 1 — Get signed URLs for all files
824
+ const { files } = await storage.getBatchUploadUrls([
537
825
  { path: 'gallery/photo1.jpg', mimeType: 'image/jpeg', size: 204800, isPublic: true },
538
826
  { path: 'gallery/photo2.jpg', mimeType: 'image/jpeg', size: 153600, isPublic: true },
827
+ { path: 'gallery/photo3.png', mimeType: 'image/png', size: 98304, isPublic: true },
539
828
  ]);
540
829
 
541
- // Upload each one
830
+ // Step 2 — Upload each directly to GCS
542
831
  for (const f of files) {
543
- await db.storage.uploadToSignedUrl(f.uploadUrl, blobs[f.index], f.mimeType);
832
+ await storage.uploadToSignedUrl(f.uploadUrl, blobs[f.index], f.mimeType);
544
833
  }
545
834
 
546
- // Confirm all at once
547
- const results = await db.storage.batchConfirmUploads(
548
- files.map(f => ({ path: f.path, mimeType: f.mimeType, isPublic: true }))
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 })),
549
838
  );
839
+
840
+ console.log(`${succeeded.length} uploaded, ${failed.length} failed`);
841
+ for (const f of succeeded) {
842
+ console.log(f.publicUrl);
843
+ }
550
844
  ```
551
845
 
552
- ### Download Files
846
+ ### Download
553
847
 
554
848
  ```typescript
555
- // Private files require authentication — download as ArrayBuffer
556
- const buffer = await db.storage.download('reports/q3.pdf');
849
+ // Private files require authentication — returns ArrayBuffer
850
+ const buffer = await db.storage('documents').download('reports/q3.pdf');
557
851
  const blob = new Blob([buffer], { type: 'application/pdf' });
558
852
 
559
- // In a browser: trigger a file download
560
- const url = URL.createObjectURL(blob);
561
- const a = document.createElement('a');
562
- a.href = url;
853
+ // Trigger browser download
854
+ const url = URL.createObjectURL(blob);
855
+ const a = document.createElement('a');
856
+ a.href = url;
563
857
  a.download = 'q3.pdf';
564
858
  a.click();
859
+ ```
860
+
861
+ > 💡 **Public files:** Use `result.publicUrl` directly — no SDK call needed. `<img src={result.publicUrl} />` just works.
862
+
863
+ ### Batch Download
864
+
865
+ Download up to 20 files in one call. Content is returned as base64 strings.
866
+
867
+ ```typescript
868
+ const { succeeded, failed } = await db.storage('documents').batchDownload([
869
+ 'reports/q1.pdf',
870
+ 'reports/q2.pdf',
871
+ 'reports/q3.pdf',
872
+ ]);
565
873
 
566
- // Public files: just use the publicUrl directly (no SDK needed)
567
- // <img src={result.publicUrl} />
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');
879
+ }
880
+
881
+ for (const f of failed) {
882
+ console.error(`${f.path}: ${f.error}`);
883
+ }
568
884
  ```
569
885
 
570
886
  ### List Files
571
887
 
572
888
  ```typescript
889
+ const storage = db.storage('main');
890
+
573
891
  // List everything at the root
574
- const { files, folders } = await db.storage.list();
892
+ const { files, folders, hasMore, nextCursor } = await storage.list();
575
893
 
576
894
  // List a specific folder
577
- const { files, folders, hasMore, nextCursor } = await db.storage.list({
578
- prefix: 'gallery/',
579
- limit: 50,
580
- recursive: false,
895
+ const result = await storage.list({
896
+ prefix: 'gallery/',
897
+ limit: 50,
581
898
  });
582
899
 
583
900
  // Paginate
584
- if (hasMore) {
585
- const page2 = await db.storage.list({ prefix: 'gallery/', cursor: nextCursor });
901
+ if (result.hasMore) {
902
+ const page2 = await storage.list({
903
+ prefix: 'gallery/',
904
+ cursor: result.nextCursor,
905
+ });
586
906
  }
587
907
  ```
588
908
 
589
- Each file entry includes:
909
+ Each file entry:
910
+
590
911
  ```typescript
591
912
  {
592
913
  name: 'photo1.jpg',
@@ -594,7 +915,7 @@ Each file entry includes:
594
915
  size: 204800,
595
916
  mimeType: 'image/jpeg',
596
917
  isPublic: true,
597
- publicUrl: 'https://storage.googleapis.com/...',
918
+ publicUrl: 'https://storage.googleapis.com/…',
598
919
  downloadUrl: null,
599
920
  updatedAt: '2025-06-01T12:00:00.000Z',
600
921
  }
@@ -602,86 +923,125 @@ Each file entry includes:
602
923
 
603
924
  ### Scoped Storage
604
925
 
605
- Working within a specific folder? Use `.scope()` to avoid typing the prefix on every call.
926
+ Pre-fix all operations to a folder great for per-user isolation.
606
927
 
607
928
  ```typescript
608
- // All operations in the "user-avatars/" folder
609
- const avatars = db.storage.scope('user-avatars');
929
+ const userDocs = db.storage('documents').scope(`users/${userId}/`);
610
930
 
611
- await avatars.upload(file, `${userId}.jpg`, { isPublic: true });
612
- // uploads to "user-avatars/{userId}.jpg"
931
+ // Uploads to: users/{userId}/contract.pdf
932
+ await userDocs.upload(pdfBuffer, 'contract.pdf');
613
933
 
614
- const { files } = await avatars.list();
615
- // lists files under "user-avatars/"
934
+ // Lists: users/{userId}/
935
+ const { files } = await userDocs.list();
616
936
 
617
- await avatars.deleteFile(`${userId}.jpg`);
618
- // → deletes "user-avatars/{userId}.jpg"
937
+ // Deletes: users/{userId}/contract.pdf
938
+ await userDocs.deleteFile('contract.pdf');
619
939
 
620
940
  // Nest scopes
621
- const thumbnails = avatars.scope('thumbnails');
622
- // all operations under "user-avatars/thumbnails/"
941
+ const userThumbs = userDocs.scope('thumbnails/');
942
+ // All ops under: users/{userId}/thumbnails/
623
943
  ```
624
944
 
625
- ### Share & Visibility
945
+ `ScopedStorage` has the same full API as `StorageManager` — every method listed in the reference is available.
946
+
947
+ ### File Metadata
626
948
 
627
949
  ```typescript
628
- // Get file metadata (sizes, URLs, visibility)
629
- const meta = await db.storage.getMetadata('reports/q3.pdf');
950
+ const meta = await db.storage('documents').getMetadata('reports/q3.pdf');
951
+
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
958
+ ```
630
959
 
631
- // Generate a time-limited share link for a private file
632
- // (no auth key needed to use the link)
633
- const { signedUrl, expiresAt } = await db.storage.getSignedUrl(
634
- 'reports/q3.pdf',
635
- 3600, // expires in 1 hour (default)
636
- );
637
- // Share signedUrl with whoever needs it
960
+ ### Signed Share URLs
961
+
962
+ Generate a time-limited link for a private file — no `X-Storage-Key` required to use it.
963
+
964
+ ```typescript
965
+ const { signedUrl, expiresAt, expiresIn } = await db.storage('documents')
966
+ .getSignedUrl('reports/q3.pdf', 3600); // expires in 1 hour (default)
638
967
 
639
- // Toggle visibility after upload
640
- const result = await db.storage.setVisibility('reports/q3.pdf', true); // make public
641
- const result2 = await db.storage.setVisibility('reports/q3.pdf', false); // make private
968
+ // Share signedUrl with the recipient — it expires automatically
969
+ console.log(`Link valid until: ${new Date(expiresAt).toLocaleString()}`);
642
970
  ```
643
971
 
644
- ### File Operations
972
+ > ⚠️ Downloads via signed URLs bypass the server — **download stats are not tracked** for those requests. Use `downloadUrl` for tracked downloads.
973
+
974
+ ### Visibility
645
975
 
646
976
  ```typescript
647
- // Rename / move a file
648
- await db.storage.move('drafts/report.pdf', 'published/report-2025.pdf');
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
984
+ ```
649
985
 
650
- // Copy a file
651
- await db.storage.copy('templates/invoice.html', 'invoices/inv-001.html');
986
+ ### Move, Copy, Delete
652
987
 
653
- // Create a folder
654
- await db.storage.createFolder('archive/2025/');
988
+ ```typescript
989
+ const storage = db.storage('main');
655
990
 
656
- // Delete a file
657
- await db.storage.deleteFile('temp/scratch.txt');
991
+ // Rename a file
992
+ await storage.move('drafts/report.pdf', 'published/report-2025.pdf');
658
993
 
659
- // Delete a folder and all its contents
660
- await db.storage.deleteFolder('temp/');
994
+ // Move to a different folder
995
+ await storage.move('inbox/data.csv', 'archive/2025/data.csv');
661
996
 
662
- // Get key-level stats
663
- const stats = await db.storage.getStats();
664
- // → { totalFiles: 842, totalBytes: 1073741824, uploadCount: 1200, ... }
997
+ // Copy
998
+ await storage.copy('templates/invoice.html', 'invoices/inv-001.html');
999
+
1000
+ // Create a folder placeholder
1001
+ await storage.createFolder('archive/2025/');
1002
+
1003
+ // Delete a single file
1004
+ await storage.deleteFile('temp/scratch.txt');
1005
+
1006
+ // Delete a folder and all its contents recursively
1007
+ await storage.deleteFolder('temp/');
1008
+ ```
1009
+
1010
+ ### Storage Stats
1011
+
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();
665
1024
  ```
666
1025
 
667
1026
  ---
668
1027
 
669
1028
  ## Analytics
670
1029
 
671
- HydrousDB Analytics runs your queries against BigQuery, so they're fast even
672
- on millions of records. All queries accept an optional `dateRange` filter.
1030
+ HydrousDB Analytics runs directly against BigQuery on your GCS data — zero ETL, no duplication, live results. Fast even on billions of records.
673
1031
 
674
1032
  ```typescript
675
1033
  const analytics = db.analytics('orders');
676
1034
  ```
677
1035
 
1036
+ All `dateRange` values are Unix timestamps in milliseconds: `{ start?: number, end?: number }`.
1037
+
678
1038
  ### Count
679
1039
 
680
1040
  ```typescript
681
1041
  // Total records
682
1042
  const { count } = await analytics.count();
683
1043
 
684
- // Records in a date range
1044
+ // In a date range
685
1045
  const { count: lastWeek } = await analytics.count({
686
1046
  dateRange: {
687
1047
  start: Date.now() - 7 * 24 * 60 * 60 * 1000,
@@ -695,8 +1055,12 @@ const { count: lastWeek } = await analytics.count({
695
1055
  How many records have each unique value for a field?
696
1056
 
697
1057
  ```typescript
698
- const rows = await analytics.distribution({ field: 'status', limit: 10, order: 'desc' });
699
- // → [
1058
+ const rows = await analytics.distribution({
1059
+ field: 'status',
1060
+ limit: 10,
1061
+ order: 'desc', // 'asc' | 'desc'
1062
+ });
1063
+ // [
700
1064
  // { value: 'completed', count: 8234 },
701
1065
  // { value: 'pending', count: 1203 },
702
1066
  // { value: 'refunded', count: 412 },
@@ -707,8 +1071,7 @@ const rows = await analytics.distribution({ field: 'status', limit: 10, order: '
707
1071
 
708
1072
  ```typescript
709
1073
  // Total revenue
710
- const rows = await analytics.sum({ field: 'amount' });
711
- // → [{ sum: 198432.50 }]
1074
+ const [{ sum: total }] = await analytics.sum({ field: 'amount' });
712
1075
 
713
1076
  // Revenue by country
714
1077
  const byCountry = await analytics.sum({
@@ -716,47 +1079,53 @@ const byCountry = await analytics.sum({
716
1079
  groupBy: 'country',
717
1080
  limit: 10,
718
1081
  });
719
- // [{ group: 'US', sum: 120000 }, { group: 'UK', sum: 45000 }, ...]
1082
+ // [{ group: 'US', sum: 120000 }, { group: 'UK', sum: 45000 }, ]
720
1083
  ```
721
1084
 
722
1085
  ### Time Series
723
1086
 
724
- Record counts over time — ideal for activity charts.
1087
+ Record counts bucketed over time — ideal for activity and growth charts.
725
1088
 
726
1089
  ```typescript
727
1090
  const rows = await analytics.timeSeries({
728
- granularity: 'day', // 'hour' | 'day' | 'week' | 'month' | 'year'
1091
+ granularity: 'day', // 'hour' | 'day' | 'week' | 'month' | 'year'
729
1092
  dateRange: {
730
1093
  start: new Date('2025-01-01').getTime(),
731
1094
  end: new Date('2025-06-01').getTime(),
732
1095
  },
733
1096
  });
734
- // [{ date: '2025-01-01', count: 42 }, { date: '2025-01-02', count: 67 }, ...]
1097
+ // [{ date: '2025-01-01', count: 42 }, { date: '2025-01-02', count: 67 }, ]
735
1098
  ```
736
1099
 
737
- Aggregate a field over time:
1100
+ ### Field Time Series
1101
+
1102
+ Aggregate a numeric field over time.
738
1103
 
739
1104
  ```typescript
740
1105
  const revenue = await analytics.fieldTimeSeries({
741
1106
  field: 'amount',
742
1107
  aggregation: 'sum', // 'sum' | 'avg' | 'min' | 'max' | 'count'
743
1108
  granularity: 'week',
1109
+ dateRange: {
1110
+ start: new Date('2025-01-01').getTime(),
1111
+ end: Date.now(),
1112
+ },
744
1113
  });
745
- // [{ date: '2025-W01', value: 12340.50 }, ...]
1114
+ // [{ date: '2025-W01', value: 12340.50 }, { date: '2025-W02', value: 9872.00 }, …]
746
1115
  ```
747
1116
 
748
1117
  ### Top N
749
1118
 
750
- Most common values for a field:
1119
+ Most common values for a field.
751
1120
 
752
1121
  ```typescript
753
1122
  const topProducts = await analytics.topN({
754
1123
  field: 'productId',
755
- labelField: 'productName', // optional: include a human-readable label
1124
+ labelField: 'productName', // optional human-readable label alongside the value
756
1125
  n: 5,
757
1126
  order: 'desc',
758
1127
  });
759
- // [
1128
+ // [
760
1129
  // { value: 'prod_123', label: 'Widget Pro', count: 892 },
761
1130
  // { value: 'prod_456', label: 'Gizmo Plus', count: 743 },
762
1131
  // ]
@@ -764,41 +1133,48 @@ const topProducts = await analytics.topN({
764
1133
 
765
1134
  ### Field Stats
766
1135
 
767
- Statistical summary for any numeric field:
1136
+ Statistical summary for any numeric field.
768
1137
 
769
1138
  ```typescript
770
1139
  const stats = await analytics.stats({ field: 'orderValue' });
771
- // {
772
- // min: 4.99, max: 9999.99, avg: 87.23,
773
- // sum: 420948.27, count: 4823, stddev: 143.2
1140
+ // {
1141
+ // min: 4.99,
1142
+ // max: 9999.99,
1143
+ // avg: 87.23,
1144
+ // sum: 420948.27,
1145
+ // count: 4823,
1146
+ // stddev: 143.2,
774
1147
  // }
775
1148
  ```
776
1149
 
777
1150
  ### Multi-Metric Dashboard
778
1151
 
779
- Calculate several aggregations in a single BigQuery query:
1152
+ Run several aggregations in a single BigQuery query — one network call.
780
1153
 
781
1154
  ```typescript
782
1155
  const dashboard = await analytics.multiMetric({
783
1156
  metrics: [
784
- { field: 'amount', name: 'totalRevenue', aggregation: 'sum' },
785
- { field: 'amount', name: 'avgOrderValue', aggregation: 'avg' },
786
- { field: 'amount', name: 'maxOrder', aggregation: 'max' },
787
- { field: 'userId', name: 'totalOrders', aggregation: 'count' },
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' },
788
1161
  ],
789
- dateRange: { start: new Date('2025-01-01').getTime(), end: Date.now() },
1162
+ dateRange: {
1163
+ start: new Date('2025-01-01').getTime(),
1164
+ end: Date.now(),
1165
+ },
790
1166
  });
791
- // {
1167
+ // {
792
1168
  // totalRevenue: 198432.50,
793
1169
  // avgOrderValue: 87.23,
794
1170
  // maxOrder: 9999.99,
795
- // totalOrders: 2275,
1171
+ // uniqueOrders: 2275,
796
1172
  // }
797
1173
  ```
798
1174
 
799
- ### Filtered Records (BigQuery)
1175
+ ### Filtered Records via BigQuery
800
1176
 
801
- Query raw records with full BigQuery speed:
1177
+ Fetch raw records using the BigQuery engine — useful for large-scale filtered exports.
802
1178
 
803
1179
  ```typescript
804
1180
  const records = await analytics.records({
@@ -807,15 +1183,20 @@ const records = await analytics.records({
807
1183
  { field: 'amount', op: '>', value: 100 },
808
1184
  ],
809
1185
  selectFields: ['orderId', 'amount', 'userId', 'createdAt'],
810
- orderBy: 'amount',
811
- order: 'desc',
812
- limit: 50,
1186
+ orderBy: 'amount',
1187
+ order: 'desc',
1188
+ limit: 50,
1189
+ offset: 0,
1190
+ dateRange: {
1191
+ start: new Date('2025-01-01').getTime(),
1192
+ end: Date.now(),
1193
+ },
813
1194
  });
814
1195
  ```
815
1196
 
816
1197
  ### Cross-Bucket Comparison
817
1198
 
818
- Compare the same metric across multiple buckets in one query:
1199
+ Compare the same metric across multiple buckets in one query.
819
1200
 
820
1201
  ```typescript
821
1202
  const comparison = await analytics.crossBucket({
@@ -823,33 +1204,87 @@ const comparison = await analytics.crossBucket({
823
1204
  field: 'amount',
824
1205
  aggregation: 'sum',
825
1206
  });
826
- // [
1207
+ // [
827
1208
  // { bucket: 'orders-us', value: 120000 },
828
1209
  // { bucket: 'orders-eu', value: 45000 },
829
1210
  // { bucket: 'orders-apac', value: 33000 },
830
1211
  // ]
831
1212
  ```
832
1213
 
833
- > ⚠️ Your Security Key must have read access to **all** buckets in the list.
1214
+ > ⚠️ Your Bucket Security Key must have read access to **every** bucket listed in `bucketKeys`.
834
1215
 
835
1216
  ### Storage Stats
836
1217
 
837
1218
  ```typescript
838
1219
  const stats = await analytics.storageStats();
839
- // { totalRecords: 48210, totalBytes: 921600000, avgBytes: 19112, minBytes: 128, maxBytes: 5242880 }
1220
+ // {
1221
+ // totalRecords: 48210,
1222
+ // totalBytes: 921600000,
1223
+ // avgBytes: 19112,
1224
+ // minBytes: 128,
1225
+ // maxBytes: 5242880,
1226
+ // }
1227
+ ```
1228
+
1229
+ ### Raw Query
1230
+
1231
+ Use the `query()` method when none of the typed helpers cover your use case.
1232
+
1233
+ ```typescript
1234
+ import type { AnalyticsQuery } from 'hydrousdb';
1235
+
1236
+ const result = await analytics.query<{ count: number }>({
1237
+ queryType: 'count',
1238
+ dateRange: { start: Date.now() - 86400000, end: Date.now() },
1239
+ });
1240
+
1241
+ console.log(result.queryType); // 'count'
1242
+ console.log(result.data); // { count: 142 }
840
1243
  ```
841
1244
 
842
1245
  ---
843
1246
 
844
- ## TypeScript Support
1247
+ ## TypeScript
845
1248
 
846
- The SDK is written in TypeScript and ships with full type definitions. Use generic
847
- type parameters to describe the shape of your records and get autocomplete throughout.
1249
+ The SDK is written entirely in TypeScript and ships full type definitions. Use generics for end-to-end type safety.
848
1250
 
849
1251
  ```typescript
850
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';
851
1286
 
852
- // Define your data models
1287
+ // Define your domain models
853
1288
  interface Order {
854
1289
  customerId: string;
855
1290
  items: Array<{ productId: string; qty: number; price: number }>;
@@ -865,250 +1300,267 @@ interface Customer {
865
1300
  credits: number;
866
1301
  }
867
1302
 
868
- const db = createClient({ securityKey: process.env.HYDROUS_SECURITY_KEY! });
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
+ });
869
1308
 
870
- // Fully typed clients
1309
+ // Fully typed — autocomplete, compile-time safety throughout
871
1310
  const orders = db.records<Order>('orders');
872
1311
  const customers = db.records<Customer>('customers');
873
1312
 
874
- // order.total, order.status, etc. are all type-safe
875
- const order = await orders.create({
876
- customerId: 'cust_abc',
877
- items: [{ productId: 'prod_1', qty: 2, price: 29.99 }],
878
- total: 59.98,
879
- status: 'pending',
880
- country: 'US',
881
- });
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
+ );
882
1317
 
883
1318
  // TypeScript catches mistakes at compile time:
884
- // order.nonExistentField // ← TS error ✓
885
- // order.status = 'invalid' // ← TS error ✓
886
- ```
887
-
888
- All exported types are available for import:
889
-
890
- ```typescript
891
- import type {
892
- HydrousConfig,
893
- RecordResult,
894
- QueryFilter,
895
- QueryOptions,
896
- UploadResult,
897
- AnalyticsQuery,
898
- DateRange,
899
- // ... and many more
900
- } from 'hydrousdb';
1319
+ // order.nonExistentField ← TS error ✓
1320
+ // orders.create({ bad: 1 }) ← TS error ✓
901
1321
  ```
902
1322
 
903
1323
  ---
904
1324
 
905
1325
  ## Error Handling
906
1326
 
907
- All errors thrown by the SDK extend `HydrousError`, which carries:
908
-
909
- | Property | Type | Description |
910
- |---|---|---|
911
- | `message` | `string` | Human-readable description |
912
- | `code` | `string` | Machine-readable error code (e.g. `"RECORD_NOT_FOUND"`) |
913
- | `status` | `number` | HTTP status code |
914
- | `requestId` | `string` | Server request ID (for support tracing) |
915
- | `details` | `string[]` | Validation error details |
1327
+ All SDK errors extend `HydrousError`. Specific sub-classes let you handle different failure modes precisely.
916
1328
 
917
1329
  ```typescript
918
- import { HydrousError, NetworkError, AuthError } from 'hydrousdb';
1330
+ import {
1331
+ HydrousError,
1332
+ AuthError,
1333
+ RecordError,
1334
+ StorageError,
1335
+ AnalyticsError,
1336
+ ValidationError,
1337
+ NetworkError,
1338
+ } from 'hydrousdb';
919
1339
 
920
1340
  try {
921
- const user = await auth.login({ email: 'a@b.com', password: 'wrong' });
1341
+ const record = await db.records('orders').get('bad-id');
922
1342
  } catch (err) {
923
- if (err instanceof AuthError) {
924
- // Authentication-specific error
925
- console.error(`Auth failed: ${err.code}`);
926
- // err.code might be: INVALID_CREDENTIALS, ACCOUNT_LOCKED, EMAIL_NOT_VERIFIED, etc.
927
- } else if (err instanceof NetworkError) {
1343
+ if (err instanceof NetworkError) {
928
1344
  // No internet / server unreachable
929
- console.error('Cannot reach HydrousDB — check your internet connection');
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
+
930
1364
  } else if (err instanceof HydrousError) {
931
1365
  // Any other API error
932
- console.error(`API error [${err.code}]: ${err.message}`);
933
- console.error(`Request ID: ${err.requestId}`); // include this in support tickets
1366
+ console.error(`API error [${err.code}] ${err.status}:`, err.message);
1367
+ console.error('Request ID:', err.requestId); // include in support tickets
934
1368
  }
935
1369
  }
936
1370
  ```
937
1371
 
1372
+ **Error properties:**
1373
+
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 |
1381
+
938
1382
  **Common error codes:**
939
1383
 
940
- | Code | Meaning |
941
- |---|---|
942
- | `RECORD_NOT_FOUND` | The requested record ID does not exist |
943
- | `INVALID_CREDENTIALS` | Wrong email or password |
944
- | `ACCOUNT_LOCKED` | The account is temporarily locked |
945
- | `INVALID_SESSION` | Session expired or revoked — re-authenticate |
946
- | `MISSING_API_KEY` | Security key not provided |
947
- | `INVALID_SECURITY_KEY` | Security key is wrong or revoked |
948
- | `FORBIDDEN` | Insufficient permissions |
949
- | `FILE_EXISTS` | File already exists at path (use `overwrite: true`) |
950
- | `LIMIT_EXCEEDED` | Storage quota or file size limit reached |
951
- | `SYSTEM_BUCKET_FORBIDDEN` | Cannot query system buckets via analytics |
952
- | `VALIDATION_ERROR` | Invalid input — check `err.details` |
953
- | `NETWORK_ERROR` | Failed to reach the API |
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 |
954
1398
 
955
1399
  ---
956
1400
 
957
1401
  ## Security Best Practices
958
1402
 
959
- 1. **Never hard-code your Security Key.** Use environment variables:
1403
+ **1. Never hard-code keys use environment variables.**
960
1404
 
961
- ```bash
962
- # .env (add to .gitignore)
963
- HYDROUS_SECURITY_KEY=sk_live_xxxxxxxxxxxxxxxxxxxx
964
- ```
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
1410
+ ```
965
1411
 
966
- ```typescript
967
- const db = createClient({ securityKey: process.env.HYDROUS_SECURITY_KEY! });
968
- ```
1412
+ ```typescript
1413
+ const db = createClient({
1414
+ authKey: process.env.HYDROUS_AUTH_KEY!,
1415
+ bucketSecurityKey: process.env.HYDROUS_BUCKET_KEY!,
1416
+ storageKeys: { main: process.env.HYDROUS_STORAGE_MAIN! },
1417
+ });
1418
+ ```
969
1419
 
970
- 2. **Never expose your Security Key to browsers.** For browser-side apps,
971
- route requests through your own backend, or use per-user session tokens.
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()`.
972
1421
 
973
- 3. **Your Security Key is sent via the `X-Api-Key` header never in URLs.**
974
- The SDK enforces this automatically, so keys never appear in server logs or
975
- browser history.
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.
976
1423
 
977
- 4. **Rotate keys periodically.** Revoke old keys from the dashboard after rotation.
1424
+ **4. Rotate keys periodically.** Revoke old keys from the dashboard after rotation.
978
1425
 
979
- 5. **Use scoped storage** (`db.storage.scope(...)`) to isolate access by user or
980
- feature, reducing the blast radius of any misconfiguration.
1426
+ **5. Use scoped storage** (`db.storage('key').scope('prefix/')`) to isolate access per user or feature, reducing the blast radius of any misconfiguration.
981
1427
 
982
- 6. **Use `isPublic: false` (the default) for sensitive files.** Use signed URLs
983
- for time-limited sharing instead of making files permanently public.
1428
+ **6. Keep files private by default.** `isPublic` defaults to `false`. Use `getSignedUrl()` for time-limited external sharing rather than making files permanently public.
984
1429
 
985
1430
  ---
986
1431
 
987
- ## API Reference
1432
+ ## Full API Reference
988
1433
 
989
- ### `createClient(config)`
1434
+ ### `createClient(config)` → `HydrousClient`
990
1435
 
991
- Creates and returns a `HydrousClient` instance. Call this once and reuse the instance.
1436
+ | Config field | Type | Required | Description |
1437
+ |---|---|---|---|
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) |
1442
+
1443
+ ---
1444
+
1445
+ ### `db.records<T>(bucketKey)` → `RecordsClient<T>`
1446
+
1447
+ | Method | Signature | Description |
1448
+ |---|---|---|
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 filters — convenience 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`:**
992
1463
 
993
1464
  ```typescript
994
- const db = createClient({
995
- securityKey: 'sk_live_...', // Required from https://hydrousdb.com/dashboard
996
- baseUrl: 'https://...', // Optional defaults to official HydrousDB endpoint
997
- });
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
+ }
998
1477
  ```
999
1478
 
1000
- ### `db.records<T>(bucketKey)`
1479
+ ---
1480
+
1481
+ ### `db.auth(bucketKey)` → `AuthClient`
1001
1482
 
1002
- Returns a `RecordsClient<T>` for the named bucket.
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. |
1003
1503
 
1004
- | Method | Description |
1005
- |---|---|
1006
- | `create(data)` | Create a new record |
1007
- | `get(id)` | Get a record by ID |
1008
- | `set(id, data)` | Full replace |
1009
- | `patch(id, data, opts?)` | Partial update (merge by default) |
1010
- | `delete(id)` | Delete a record |
1011
- | `query(opts?)` | Query with filters, sort, pagination |
1012
- | `getAll(opts?)` | Shortcut for query without filters |
1013
- | `count(filters?)` | Count matching records |
1014
- | `batchCreate(items)` | Create multiple records |
1015
- | `batchDelete(ids)` | Delete multiple records |
1016
- | `getHistory(id)` | Get version history |
1017
- | `restoreVersion(id, version)` | Restore to a version |
1018
-
1019
- ### `db.auth(bucketKey)`
1020
-
1021
- Returns an `AuthClient` for the named user bucket.
1022
-
1023
- | Method | Description |
1024
- |---|---|
1025
- | `signup(opts)` | Register a new user |
1026
- | `login(opts)` | Authenticate and create session |
1027
- | `logout({ sessionId })` | Invalidate session |
1028
- | `refreshSession({ refreshToken })` | Extend a session |
1029
- | `getUser({ userId })` | Get user by ID |
1030
- | `updateUser(opts)` | Update user fields |
1031
- | `deleteUser(opts)` | Soft-delete a user |
1032
- | `hardDeleteUser(opts)` | Permanently delete a user (admin) |
1033
- | `listUsers(opts)` | List all users (admin) |
1034
- | `bulkDeleteUsers(opts)` | Bulk delete (admin) |
1035
- | `lockAccount(opts)` | Lock a user (admin) |
1036
- | `unlockAccount(opts)` | Unlock a user (admin) |
1037
- | `changePassword(opts)` | Change password (authenticated) |
1038
- | `requestPasswordReset(opts)` | Send reset email |
1039
- | `confirmPasswordReset(opts)` | Apply new password |
1040
- | `requestEmailVerification(opts)` | Send verification email |
1041
- | `confirmEmailVerification(opts)` | Verify email with token |
1042
-
1043
- ### `db.analytics(bucketKey)`
1044
-
1045
- Returns an `AnalyticsClient` for the named bucket.
1046
-
1047
- | Method | Description |
1048
- |---|---|
1049
- | `count(opts?)` | Count records |
1050
- | `distribution(opts)` | Value distribution for a field |
1051
- | `sum(opts)` | Sum (with optional groupBy) |
1052
- | `timeSeries(opts?)` | Records over time |
1053
- | `fieldTimeSeries(opts)` | Field aggregation over time |
1054
- | `topN(opts)` | Top N values for a field |
1055
- | `stats(opts)` | Statistical summary for a field |
1056
- | `records(opts?)` | Filtered raw records (BigQuery) |
1057
- | `multiMetric(opts)` | Multiple aggregations in one query |
1058
- | `storageStats(opts?)` | Bucket storage statistics |
1059
- | `crossBucket(opts)` | Compare across multiple buckets |
1060
- | `query(query)` | Raw analytics query |
1061
-
1062
- ### `db.storage`
1063
-
1064
- The `StorageManager` instance. Also has `.scope(prefix)` for folder-scoped access.
1065
-
1066
- | Method | Description |
1067
- |---|---|
1068
- | `upload(data, path, opts?)` | Simple server-buffered upload |
1069
- | `uploadRaw(data, path, opts?)` | Upload JSON/text data |
1070
- | `getUploadUrl(opts)` | Step 1: Get signed GCS upload URL |
1071
- | `uploadToSignedUrl(url, data, mime, onProgress?)` | Step 2: Upload to GCS directly |
1072
- | `confirmUpload(opts)` | Step 3: Register upload metadata |
1073
- | `getBatchUploadUrls(files)` | Batch signed upload URLs |
1074
- | `batchConfirmUploads(items)` | Confirm batch uploads |
1075
- | `download(path)` | Download private file |
1076
- | `batchDownload(paths)` | Batch download |
1077
- | `list(opts?)` | List files and folders |
1078
- | `getMetadata(path)` | File metadata |
1079
- | `getSignedUrl(path, expiresIn?)` | Time-limited share URL |
1080
- | `setVisibility(path, isPublic)` | Toggle public/private |
1081
- | `createFolder(path)` | Create a folder |
1082
- | `deleteFile(path)` | Delete a file |
1083
- | `deleteFolder(path)` | Delete a folder recursively |
1084
- | `move(from, to)` | Move/rename |
1085
- | `copy(from, to)` | Copy |
1086
- | `getStats()` | Key-level stats |
1087
- | `info()` | Server ping (no auth) |
1088
- | `scope(prefix)` | Get a ScopedStorage instance |
1504
+ ---
1505
+
1506
+ ### `db.storage(keyName)` `StorageManager`
1507
+
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. |
1089
1531
 
1090
1532
  ---
1091
1533
 
1092
- ## Contributing
1534
+ ### `db.analytics(bucketKey)` → `AnalyticsClient`
1093
1535
 
1094
- We love contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
1536
+ | Method | Signature | Description |
1537
+ |---|---|---|
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. |
1550
+
1551
+ ---
1552
+
1553
+ ## Contributing
1095
1554
 
1096
1555
  ```bash
1097
- # Clone the repo
1098
1556
  git clone https://github.com/hydrousdb/hydrousdb-js.git
1099
1557
  cd hydrousdb-js
1100
1558
 
1101
- # Install dependencies
1102
- npm install
1103
-
1104
- # Run tests
1105
- npm test
1106
-
1107
- # Build
1108
- npm run build
1109
-
1110
- # Run tests in watch mode
1111
- npm run test:watch
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
1112
1564
  ```
1113
1565
 
1114
1566
  ---
@@ -1121,6 +1573,6 @@ MIT — see [LICENSE](./LICENSE) for details.
1121
1573
 
1122
1574
  <p align="center">
1123
1575
  Built with ❤️ by the <a href="https://hydrousdb.com">HydrousDB</a> team.<br>
1124
- Questions? <a href="mailto:support@hydrousdb.com">support@hydrousdb.com</a> ·
1576
+ Questions? <a href="mailto:support@hydrousdb.com">support@hydrousdb.com</a> ·
1125
1577
  <a href="https://github.com/hydrousdb/hydrousdb-js/issues">Open an issue</a>
1126
1578
  </p>