openbase-js 0.1.7 ā 0.1.8
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 +182 -0
- package/index.cjs +24 -28
- package/index.d.ts +10 -1
- package/index.js +26 -30
- package/openbase_readme.md +115 -0
- package/package.json +1 -1
- package/test.js +57 -39
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# OpenBase JS SDK
|
|
2
|
+
|
|
3
|
+
The OpenBase JS SDK provides a lightweight, expressive interface for interacting with your OpenBase projects. It handles database queries (PostgreSQL), file storage (S3/MinIO), and realtime subscriptions via a simple, Supabase-like API.
|
|
4
|
+
|
|
5
|
+
## š¦ Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install openbase-js
|
|
9
|
+
# or
|
|
10
|
+
yarn add openbase-js
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> [!NOTE]
|
|
14
|
+
> If you are using Node.js for file uploads, you will also need `form-data`:
|
|
15
|
+
> `npm install form-data`
|
|
16
|
+
|
|
17
|
+
## š Quick Start
|
|
18
|
+
|
|
19
|
+
Initialize the client with your project URL, API key, and database name.
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
const { createClient } = require('openbase-js');
|
|
23
|
+
|
|
24
|
+
const openbase = createClient(
|
|
25
|
+
'http://localhost:3003', // The URL where your OpenBase backend is running
|
|
26
|
+
'your-anon-key',
|
|
27
|
+
'your-db-name'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Fetch data
|
|
31
|
+
async function getLeads() {
|
|
32
|
+
const { data, error } = await openbase
|
|
33
|
+
.from('leads')
|
|
34
|
+
.select('*')
|
|
35
|
+
.eq('status', 'active');
|
|
36
|
+
|
|
37
|
+
if (error) console.error(error);
|
|
38
|
+
else console.log(data);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## š Database Operations
|
|
45
|
+
|
|
46
|
+
OpenBase uses a chainable query builder for database operations.
|
|
47
|
+
|
|
48
|
+
### Basic CRUD
|
|
49
|
+
|
|
50
|
+
#### Select
|
|
51
|
+
```javascript
|
|
52
|
+
const { data } = await openbase.from('users').select('id, username');
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
#### Insert
|
|
56
|
+
```javascript
|
|
57
|
+
const { data } = await openbase.from('users').insert({
|
|
58
|
+
username: 'jdoe',
|
|
59
|
+
email: 'john@example.com'
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### Update
|
|
64
|
+
```javascript
|
|
65
|
+
const { data } = await openbase.from('users')
|
|
66
|
+
.update({ email: 'newemail@example.com' })
|
|
67
|
+
.eq('id', 1);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### Delete
|
|
71
|
+
```javascript
|
|
72
|
+
const { data } = await openbase.from('users')
|
|
73
|
+
.delete()
|
|
74
|
+
.eq('id', 1);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### š Detailed Operator Guide
|
|
78
|
+
|
|
79
|
+
Filtering is done using chainable operator methods.
|
|
80
|
+
|
|
81
|
+
| Operator | Method | Description | Example |
|
|
82
|
+
| :--- | :--- | :--- | :--- |
|
|
83
|
+
| `==` | `.eq(col, val)` | Equal to | `.eq('name', 'Alice')` |
|
|
84
|
+
| `>` | `.gt(col, val)` | Greater than | `.gt('age', 21)` |
|
|
85
|
+
| `<` | `.lt(col, val)` | Less than | `.lt('score', 50)` |
|
|
86
|
+
| `>=` | `.gte(col, val)` | Greater than or equal to | `.gte('price', 100)` |
|
|
87
|
+
| `<=` | `.lte(col, val)` | Less than or equal to | `.lte('stock', 5)` |
|
|
88
|
+
| `LIKE` | `.like(col, pattern)` | Case-sensitive pattern match | `.like('name', '%son%')` |
|
|
89
|
+
| `ILIKE` | `.ilike(col, pattern)`| Case-insensitive pattern match | `.ilike('name', '%SON%')` |
|
|
90
|
+
| `IN` | `.in(col, array)` | Contained in array | `.in('role', ['admin', 'mod'])` |
|
|
91
|
+
| `IS` | `.is(col, value)` | Check for `null` or `boolean` | `.is('deleted_at', null)` |
|
|
92
|
+
|
|
93
|
+
### š ļø Modifiers
|
|
94
|
+
|
|
95
|
+
* **`.order(column, { ascending: boolean })`**: Sort the results.
|
|
96
|
+
* **`.limit(count)`**: Limit the number of rows returned.
|
|
97
|
+
* **`.range(from, to)`**: Pagination (0-indexed, inclusive).
|
|
98
|
+
* **`.single()`**: Returns a single object instead of an array.
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
const { data } = await openbase
|
|
102
|
+
.from('posts')
|
|
103
|
+
.select('*')
|
|
104
|
+
.gt('likes', 10)
|
|
105
|
+
.order('created_at', { ascending: false })
|
|
106
|
+
.range(0, 9) // First 10 items
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## š Storage
|
|
112
|
+
|
|
113
|
+
OpenBase provides built-in S3-compatible storage.
|
|
114
|
+
|
|
115
|
+
### Uploading Files
|
|
116
|
+
|
|
117
|
+
In the browser, you can pass a `File` or `Blob`. In Node.js, you can pass a file path, `Buffer`, or `ReadStream`.
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
// Browser
|
|
121
|
+
const file = event.target.files[0];
|
|
122
|
+
const { data, error } = await openbase.storage.upload(file, 'profile.jpg');
|
|
123
|
+
|
|
124
|
+
// Node.js (requires form-data)
|
|
125
|
+
const { data, error } = await openbase.storage.upload('./local-image.png', 'remote-name.png');
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Listing & Managing Files
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
// List all files in bucket
|
|
132
|
+
const { data: files } = await openbase.storage.list();
|
|
133
|
+
|
|
134
|
+
// Get a public presigned URL (valid for 7 days)
|
|
135
|
+
const { data: { url } } = await openbase.storage.getUrl('profile.jpg');
|
|
136
|
+
|
|
137
|
+
// Remove a file
|
|
138
|
+
await openbase.storage.remove('profile.jpg');
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## š Realtime
|
|
144
|
+
|
|
145
|
+
Subscribe to database changes using WebSockets.
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
const subscription = openbase
|
|
149
|
+
.from('messages')
|
|
150
|
+
.on('INSERT', (payload) => {
|
|
151
|
+
console.log('New message:', payload.record);
|
|
152
|
+
})
|
|
153
|
+
.subscribe();
|
|
154
|
+
|
|
155
|
+
// To stop listening
|
|
156
|
+
// subscription.unsubscribe();
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## āØļø TypeScript Support
|
|
162
|
+
|
|
163
|
+
The SDK is written with full TypeScript support. Types are automatically included.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
interface Lead {
|
|
167
|
+
id: number;
|
|
168
|
+
company_name: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { data } = await openbase.from<Lead>('leads').select('*');
|
|
172
|
+
// data is typed as Lead[] | null
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## š§Ŗ Testing
|
|
178
|
+
|
|
179
|
+
To run the local tests:
|
|
180
|
+
1. Ensure the OpenBase backend is running.
|
|
181
|
+
2. Update the credentials in `test.js`.
|
|
182
|
+
3. Run: `node test.js`
|
package/index.cjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// āāā Query Builder āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2
2
|
|
|
3
3
|
class QueryBuilder {
|
|
4
|
-
constructor(baseUrl, apiKey, dbName, table) {
|
|
4
|
+
constructor(baseUrl, apiKey, dbName, table, anonKey) {
|
|
5
5
|
this._baseUrl = baseUrl;
|
|
6
6
|
this._apiKey = apiKey;
|
|
7
7
|
this._dbName = dbName;
|
|
8
8
|
this._table = table;
|
|
9
|
+
this._anonKey = anonKey;
|
|
9
10
|
this._filters = [];
|
|
10
11
|
this._columns = '*';
|
|
11
12
|
this._limitVal = null;
|
|
@@ -27,7 +28,7 @@ class QueryBuilder {
|
|
|
27
28
|
on(event, callback) {
|
|
28
29
|
if (!this._realtimeSub) {
|
|
29
30
|
this._realtimeSub = new RealtimeSubscription(
|
|
30
|
-
this._baseUrl, this._apiKey, this._dbName, this._table
|
|
31
|
+
this._baseUrl, this._apiKey, this._dbName, this._table, this._anonKey
|
|
31
32
|
);
|
|
32
33
|
}
|
|
33
34
|
this._realtimeSub.on(event, callback);
|
|
@@ -37,7 +38,7 @@ class QueryBuilder {
|
|
|
37
38
|
subscribe() {
|
|
38
39
|
if (!this._realtimeSub) {
|
|
39
40
|
this._realtimeSub = new RealtimeSubscription(
|
|
40
|
-
this._baseUrl, this._apiKey, this._dbName, this._table
|
|
41
|
+
this._baseUrl, this._apiKey, this._dbName, this._table, this._anonKey
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
return this._realtimeSub.subscribe();
|
|
@@ -215,6 +216,7 @@ class QueryBuilder {
|
|
|
215
216
|
headers: {
|
|
216
217
|
'Content-Type': 'application/json',
|
|
217
218
|
'Authorization': `Bearer ${this._apiKey}`,
|
|
219
|
+
'X-Anon-Key': this._anonKey,
|
|
218
220
|
},
|
|
219
221
|
body: JSON.stringify({ sql, db_name: this._dbName }),
|
|
220
222
|
});
|
|
@@ -237,14 +239,18 @@ class QueryBuilder {
|
|
|
237
239
|
// āāā Storage Client āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
238
240
|
|
|
239
241
|
class StorageClient {
|
|
240
|
-
constructor(baseUrl, apiKey, dbName) {
|
|
242
|
+
constructor(baseUrl, apiKey, dbName, anonKey) {
|
|
241
243
|
this._baseUrl = baseUrl;
|
|
242
244
|
this._apiKey = apiKey;
|
|
243
245
|
this._dbName = dbName;
|
|
246
|
+
this._anonKey = anonKey;
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
_headers() {
|
|
247
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
'Authorization': `Bearer ${this._apiKey}`,
|
|
252
|
+
'X-Anon-Key': this._anonKey
|
|
253
|
+
};
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
// Detect if we're running in Node.js
|
|
@@ -253,8 +259,6 @@ class StorageClient {
|
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
// Build a FormData object that works in both browser and Node.js
|
|
256
|
-
// In browser: file = File or Blob object
|
|
257
|
-
// In Node.js: file = file path string, Buffer, or ReadStream
|
|
258
262
|
async _buildFormData(file, filename) {
|
|
259
263
|
if (this._isNode()) {
|
|
260
264
|
// Node.js ā use form-data package
|
|
@@ -273,14 +277,11 @@ class StorageClient {
|
|
|
273
277
|
const formData = new FormDataNode();
|
|
274
278
|
|
|
275
279
|
if (typeof file === 'string') {
|
|
276
|
-
// file path string ā read from disk
|
|
277
280
|
const resolvedName = filename || path.basename(file);
|
|
278
281
|
formData.append('file', fs.createReadStream(file), resolvedName);
|
|
279
282
|
} else if (Buffer.isBuffer(file)) {
|
|
280
|
-
// Buffer
|
|
281
283
|
formData.append('file', file, filename || 'file');
|
|
282
284
|
} else {
|
|
283
|
-
// ReadStream or anything else
|
|
284
285
|
formData.append('file', file, filename || 'file');
|
|
285
286
|
}
|
|
286
287
|
|
|
@@ -294,14 +295,10 @@ class StorageClient {
|
|
|
294
295
|
}
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
// Upload a file
|
|
298
|
-
// Browser: pass a File or Blob (from <input type="file"> or drag-and-drop)
|
|
299
|
-
// Node.js: pass a file path string, Buffer, or ReadStream
|
|
300
298
|
async upload(file, filename) {
|
|
301
299
|
try {
|
|
302
300
|
const formData = await this._buildFormData(file, filename);
|
|
303
301
|
|
|
304
|
-
// In Node.js, form-data needs its own headers (includes boundary)
|
|
305
302
|
const headers = this._isNode()
|
|
306
303
|
? { ...this._headers(), ...formData.getHeaders() }
|
|
307
304
|
: this._headers();
|
|
@@ -317,7 +314,6 @@ class StorageClient {
|
|
|
317
314
|
}
|
|
318
315
|
}
|
|
319
316
|
|
|
320
|
-
// List all files in this project's bucket
|
|
321
317
|
async list() {
|
|
322
318
|
try {
|
|
323
319
|
const res = await fetch(`${this._baseUrl}/storage/${this._dbName}/list`, {
|
|
@@ -329,7 +325,6 @@ class StorageClient {
|
|
|
329
325
|
}
|
|
330
326
|
}
|
|
331
327
|
|
|
332
|
-
// Get a presigned URL valid for 7 days
|
|
333
328
|
async getUrl(filename) {
|
|
334
329
|
try {
|
|
335
330
|
const res = await fetch(`${this._baseUrl}/storage/${this._dbName}/url/${encodeURIComponent(filename)}`, {
|
|
@@ -341,7 +336,6 @@ class StorageClient {
|
|
|
341
336
|
}
|
|
342
337
|
}
|
|
343
338
|
|
|
344
|
-
// Delete a file
|
|
345
339
|
async remove(filename) {
|
|
346
340
|
try {
|
|
347
341
|
const res = await fetch(`${this._baseUrl}/storage/${this._dbName}/${encodeURIComponent(filename)}`, {
|
|
@@ -358,12 +352,13 @@ class StorageClient {
|
|
|
358
352
|
// āāā Realtime Subscription āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
359
353
|
|
|
360
354
|
class RealtimeSubscription {
|
|
361
|
-
constructor(baseUrl, apiKey, dbName, table) {
|
|
355
|
+
constructor(baseUrl, apiKey, dbName, table, anonKey) {
|
|
362
356
|
this._baseUrl = baseUrl.replace('http://', 'ws://').replace('https://', 'wss://');
|
|
363
357
|
this._apiKey = apiKey;
|
|
364
358
|
this._dbName = dbName;
|
|
365
359
|
this._table = table;
|
|
366
|
-
this.
|
|
360
|
+
this._anonKey = anonKey;
|
|
361
|
+
this._listeners = {};
|
|
367
362
|
this._ws = null;
|
|
368
363
|
}
|
|
369
364
|
|
|
@@ -374,7 +369,7 @@ class RealtimeSubscription {
|
|
|
374
369
|
}
|
|
375
370
|
|
|
376
371
|
subscribe() {
|
|
377
|
-
const url = `${this._baseUrl}/realtime/${this._dbName}/${this._table}?token=${this._apiKey}`;
|
|
372
|
+
const url = `${this._baseUrl}/realtime/${this._dbName}/${this._table}?token=${this._apiKey}&anon_key=${this._anonKey}`;
|
|
378
373
|
this._ws = new WebSocket(url);
|
|
379
374
|
|
|
380
375
|
this._ws.onmessage = (e) => {
|
|
@@ -404,25 +399,26 @@ class RealtimeSubscription {
|
|
|
404
399
|
// āāā Client āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
405
400
|
|
|
406
401
|
class OpenbaseClient {
|
|
407
|
-
constructor(baseUrl, apiKey, dbName) {
|
|
402
|
+
constructor(baseUrl, apiKey, dbName, options = {}) {
|
|
408
403
|
this._baseUrl = baseUrl.replace(/\/$/, '');
|
|
409
404
|
this._apiKey = apiKey;
|
|
410
405
|
this._dbName = dbName;
|
|
411
|
-
this.
|
|
406
|
+
this._anonKey = options.anonKey || apiKey;
|
|
407
|
+
this.storage = new StorageClient(this._baseUrl, this._apiKey, this._dbName, this._anonKey);
|
|
412
408
|
}
|
|
413
409
|
|
|
414
410
|
from(table) {
|
|
415
|
-
return new QueryBuilder(this._baseUrl, this._apiKey, this._dbName, table);
|
|
411
|
+
return new QueryBuilder(this._baseUrl, this._apiKey, this._dbName, table, this._anonKey);
|
|
416
412
|
}
|
|
417
413
|
}
|
|
418
414
|
|
|
419
415
|
// āāā Factory āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
420
416
|
|
|
421
|
-
function createClient(baseUrl, apiKey, dbName) {
|
|
422
|
-
if (!baseUrl) throw new Error('[openbase] Missing baseUrl.
|
|
423
|
-
if (!apiKey) throw new Error('[openbase] Missing apiKey.
|
|
424
|
-
if (!dbName) throw new Error('[openbase] Missing dbName.
|
|
425
|
-
return new OpenbaseClient(baseUrl, apiKey, dbName);
|
|
417
|
+
function createClient(baseUrl, apiKey, dbName, options = {}) {
|
|
418
|
+
if (!baseUrl) throw new Error('[openbase] Missing baseUrl.');
|
|
419
|
+
if (!apiKey) throw new Error('[openbase] Missing apiKey.');
|
|
420
|
+
if (!dbName) throw new Error('[openbase] Missing dbName.');
|
|
421
|
+
return new OpenbaseClient(baseUrl, apiKey, dbName, options);
|
|
426
422
|
}
|
|
427
423
|
|
|
428
424
|
if (typeof module !== 'undefined') {
|
package/index.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface OpenbaseResponse<T> {
|
|
|
6
6
|
error: string | null;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export interface OpenbaseClientOptions {
|
|
10
|
+
/**
|
|
11
|
+
* The public Anon Key for your project.
|
|
12
|
+
* If not provided, the 'apiKey' argument will be used as the project identifier.
|
|
13
|
+
*/
|
|
14
|
+
anonKey?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
export declare class QueryBuilder<T = Record<string, unknown>> {
|
|
10
18
|
// Column selection
|
|
11
19
|
select(columns?: string): this;
|
|
@@ -98,7 +106,8 @@ export declare class OpenbaseClient {
|
|
|
98
106
|
export declare function createClient(
|
|
99
107
|
baseUrl: string,
|
|
100
108
|
apiKey: string,
|
|
101
|
-
dbName: string
|
|
109
|
+
dbName: string,
|
|
110
|
+
options?: OpenbaseClientOptions
|
|
102
111
|
): OpenbaseClient;
|
|
103
112
|
|
|
104
113
|
export declare class RealtimeSubscription {
|
package/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// āāā Query Builder āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2
2
|
|
|
3
3
|
class QueryBuilder {
|
|
4
|
-
constructor(baseUrl, apiKey, dbName, table) {
|
|
4
|
+
constructor(baseUrl, apiKey, dbName, table, anonKey) {
|
|
5
5
|
this._baseUrl = baseUrl;
|
|
6
6
|
this._apiKey = apiKey;
|
|
7
7
|
this._dbName = dbName;
|
|
8
8
|
this._table = table;
|
|
9
|
+
this._anonKey = anonKey;
|
|
9
10
|
this._filters = [];
|
|
10
11
|
this._columns = '*';
|
|
11
12
|
this._limitVal = null;
|
|
@@ -27,7 +28,7 @@ class QueryBuilder {
|
|
|
27
28
|
on(event, callback) {
|
|
28
29
|
if (!this._realtimeSub) {
|
|
29
30
|
this._realtimeSub = new RealtimeSubscription(
|
|
30
|
-
this._baseUrl, this._apiKey, this._dbName, this._table
|
|
31
|
+
this._baseUrl, this._apiKey, this._dbName, this._table, this._anonKey
|
|
31
32
|
);
|
|
32
33
|
}
|
|
33
34
|
this._realtimeSub.on(event, callback);
|
|
@@ -37,7 +38,7 @@ class QueryBuilder {
|
|
|
37
38
|
subscribe() {
|
|
38
39
|
if (!this._realtimeSub) {
|
|
39
40
|
this._realtimeSub = new RealtimeSubscription(
|
|
40
|
-
this._baseUrl, this._apiKey, this._dbName, this._table
|
|
41
|
+
this._baseUrl, this._apiKey, this._dbName, this._table, this._anonKey
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
return this._realtimeSub.subscribe();
|
|
@@ -196,7 +197,7 @@ class QueryBuilder {
|
|
|
196
197
|
} else if (this._operation === 'delete') {
|
|
197
198
|
sql = `DELETE FROM "${this._table}"`;
|
|
198
199
|
if (this._filters.length) {
|
|
199
|
-
const where = this._filters.map(f => this._filterToSQL(f)).join(' AND ');
|
|
200
|
+
const where = this._filters.map(f => this._filters.map(f => this._filterToSQL(f)).join(' AND '));
|
|
200
201
|
sql += ` WHERE ${where}`;
|
|
201
202
|
}
|
|
202
203
|
sql += ` RETURNING *`;
|
|
@@ -215,6 +216,7 @@ class QueryBuilder {
|
|
|
215
216
|
headers: {
|
|
216
217
|
'Content-Type': 'application/json',
|
|
217
218
|
'Authorization': `Bearer ${this._apiKey}`,
|
|
219
|
+
'X-Anon-Key': this._anonKey,
|
|
218
220
|
},
|
|
219
221
|
body: JSON.stringify({ sql, db_name: this._dbName }),
|
|
220
222
|
});
|
|
@@ -237,14 +239,18 @@ class QueryBuilder {
|
|
|
237
239
|
// āāā Storage Client āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
238
240
|
|
|
239
241
|
class StorageClient {
|
|
240
|
-
constructor(baseUrl, apiKey, dbName) {
|
|
242
|
+
constructor(baseUrl, apiKey, dbName, anonKey) {
|
|
241
243
|
this._baseUrl = baseUrl;
|
|
242
244
|
this._apiKey = apiKey;
|
|
243
245
|
this._dbName = dbName;
|
|
246
|
+
this._anonKey = anonKey;
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
_headers() {
|
|
247
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
'Authorization': `Bearer ${this._apiKey}`,
|
|
252
|
+
'X-Anon-Key': this._anonKey
|
|
253
|
+
};
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
// Detect if we're running in Node.js
|
|
@@ -253,8 +259,6 @@ class StorageClient {
|
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
// Build a FormData object that works in both browser and Node.js
|
|
256
|
-
// In browser: file = File or Blob object
|
|
257
|
-
// In Node.js: file = file path string, Buffer, or ReadStream
|
|
258
262
|
async _buildFormData(file, filename) {
|
|
259
263
|
if (this._isNode()) {
|
|
260
264
|
// Node.js ā use form-data package
|
|
@@ -273,14 +277,11 @@ class StorageClient {
|
|
|
273
277
|
const formData = new FormDataNode();
|
|
274
278
|
|
|
275
279
|
if (typeof file === 'string') {
|
|
276
|
-
// file path string ā read from disk
|
|
277
280
|
const resolvedName = filename || path.basename(file);
|
|
278
281
|
formData.append('file', fs.createReadStream(file), resolvedName);
|
|
279
282
|
} else if (Buffer.isBuffer(file)) {
|
|
280
|
-
// Buffer
|
|
281
283
|
formData.append('file', file, filename || 'file');
|
|
282
284
|
} else {
|
|
283
|
-
// ReadStream or anything else
|
|
284
285
|
formData.append('file', file, filename || 'file');
|
|
285
286
|
}
|
|
286
287
|
|
|
@@ -294,14 +295,10 @@ class StorageClient {
|
|
|
294
295
|
}
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
// Upload a file
|
|
298
|
-
// Browser: pass a File or Blob (from <input type="file"> or drag-and-drop)
|
|
299
|
-
// Node.js: pass a file path string, Buffer, or ReadStream
|
|
300
298
|
async upload(file, filename) {
|
|
301
299
|
try {
|
|
302
300
|
const formData = await this._buildFormData(file, filename);
|
|
303
301
|
|
|
304
|
-
// In Node.js, form-data needs its own headers (includes boundary)
|
|
305
302
|
const headers = this._isNode()
|
|
306
303
|
? { ...this._headers(), ...formData.getHeaders() }
|
|
307
304
|
: this._headers();
|
|
@@ -317,7 +314,6 @@ class StorageClient {
|
|
|
317
314
|
}
|
|
318
315
|
}
|
|
319
316
|
|
|
320
|
-
// List all files in this project's bucket
|
|
321
317
|
async list() {
|
|
322
318
|
try {
|
|
323
319
|
const res = await fetch(`${this._baseUrl}/storage/${this._dbName}/list`, {
|
|
@@ -329,7 +325,6 @@ class StorageClient {
|
|
|
329
325
|
}
|
|
330
326
|
}
|
|
331
327
|
|
|
332
|
-
// Get a presigned URL valid for 7 days
|
|
333
328
|
async getUrl(filename) {
|
|
334
329
|
try {
|
|
335
330
|
const res = await fetch(`${this._baseUrl}/storage/${this._dbName}/url/${encodeURIComponent(filename)}`, {
|
|
@@ -341,7 +336,6 @@ class StorageClient {
|
|
|
341
336
|
}
|
|
342
337
|
}
|
|
343
338
|
|
|
344
|
-
// Delete a file
|
|
345
339
|
async remove(filename) {
|
|
346
340
|
try {
|
|
347
341
|
const res = await fetch(`${this._baseUrl}/storage/${this._dbName}/${encodeURIComponent(filename)}`, {
|
|
@@ -358,12 +352,13 @@ class StorageClient {
|
|
|
358
352
|
// āāā Realtime Subscription āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
359
353
|
|
|
360
354
|
class RealtimeSubscription {
|
|
361
|
-
constructor(baseUrl, apiKey, dbName, table) {
|
|
355
|
+
constructor(baseUrl, apiKey, dbName, table, anonKey) {
|
|
362
356
|
this._baseUrl = baseUrl.replace('http://', 'ws://').replace('https://', 'wss://');
|
|
363
357
|
this._apiKey = apiKey;
|
|
364
358
|
this._dbName = dbName;
|
|
365
359
|
this._table = table;
|
|
366
|
-
this.
|
|
360
|
+
this._anonKey = anonKey;
|
|
361
|
+
this._listeners = {};
|
|
367
362
|
this._ws = null;
|
|
368
363
|
}
|
|
369
364
|
|
|
@@ -374,7 +369,7 @@ class RealtimeSubscription {
|
|
|
374
369
|
}
|
|
375
370
|
|
|
376
371
|
subscribe() {
|
|
377
|
-
const url = `${this._baseUrl}/realtime/${this._dbName}/${this._table}?token=${this._apiKey}`;
|
|
372
|
+
const url = `${this._baseUrl}/realtime/${this._dbName}/${this._table}?token=${this._apiKey}&anon_key=${this._anonKey}`;
|
|
378
373
|
this._ws = new WebSocket(url);
|
|
379
374
|
|
|
380
375
|
this._ws.onmessage = (e) => {
|
|
@@ -404,25 +399,26 @@ class RealtimeSubscription {
|
|
|
404
399
|
// āāā Client āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
405
400
|
|
|
406
401
|
class OpenbaseClient {
|
|
407
|
-
constructor(baseUrl, apiKey, dbName) {
|
|
402
|
+
constructor(baseUrl, apiKey, dbName, options = {}) {
|
|
408
403
|
this._baseUrl = baseUrl.replace(/\/$/, '');
|
|
409
404
|
this._apiKey = apiKey;
|
|
410
405
|
this._dbName = dbName;
|
|
411
|
-
this.
|
|
406
|
+
this._anonKey = options.anonKey || apiKey; // Default to apiKey if anonKey not provided
|
|
407
|
+
this.storage = new StorageClient(this._baseUrl, this._apiKey, this._dbName, this._anonKey);
|
|
412
408
|
}
|
|
413
409
|
|
|
414
410
|
from(table) {
|
|
415
|
-
return new QueryBuilder(this._baseUrl, this._apiKey, this._dbName, table);
|
|
411
|
+
return new QueryBuilder(this._baseUrl, this._apiKey, this._dbName, table, this._anonKey);
|
|
416
412
|
}
|
|
417
413
|
}
|
|
418
414
|
|
|
419
415
|
// āāā Factory āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
420
416
|
|
|
421
|
-
function createClient(baseUrl, apiKey, dbName) {
|
|
422
|
-
if (!baseUrl) throw new Error('[openbase] Missing baseUrl.
|
|
423
|
-
if (!apiKey) throw new Error('[openbase] Missing apiKey.
|
|
424
|
-
if (!dbName) throw new Error('[openbase] Missing dbName.
|
|
425
|
-
return new OpenbaseClient(baseUrl, apiKey, dbName);
|
|
417
|
+
function createClient(baseUrl, apiKey, dbName, options = {}) {
|
|
418
|
+
if (!baseUrl) throw new Error('[openbase] Missing baseUrl.');
|
|
419
|
+
if (!apiKey) throw new Error('[openbase] Missing apiKey.');
|
|
420
|
+
if (!dbName) throw new Error('[openbase] Missing dbName.');
|
|
421
|
+
return new OpenbaseClient(baseUrl, apiKey, dbName, options);
|
|
426
422
|
}
|
|
427
423
|
|
|
428
424
|
if (typeof module !== 'undefined') {
|
|
@@ -431,4 +427,4 @@ if (typeof module !== 'undefined') {
|
|
|
431
427
|
|
|
432
428
|
if (typeof window !== 'undefined') {
|
|
433
429
|
window.openbase = { createClient };
|
|
434
|
-
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# OpenBase
|
|
2
|
+
|
|
3
|
+
OpenBase is an open-source, lightweight alternative to Supabase, designed for rapid development with a focus on PostgreSQL and S3-compatible storage (MinIO). It provides a unified dashboard for managing projects, databases, storage, and monitoring.
|
|
4
|
+
|
|
5
|
+
## š Key Features
|
|
6
|
+
|
|
7
|
+
- **Project Management**: Easily create and manage multiple isolated projects.
|
|
8
|
+
- **Dynamic Database API**: Auto-generated RESTful API for PostgreSQL databases using a custom FastAPI backend.
|
|
9
|
+
- **Integrated S3 Storage**: Built-in file storage powered by MinIO, with project-level isolation.
|
|
10
|
+
- **SQL Editor**: Execute SQL queries directly from the dashboard.
|
|
11
|
+
- **Monitoring & Logs**: Real-time container monitoring and log streaming.
|
|
12
|
+
- **OpenBase SDK**: A lightweight JavaScript/TypeScript SDK for seamless integration into your applications.
|
|
13
|
+
|
|
14
|
+
## šļø Architecture
|
|
15
|
+
|
|
16
|
+
OpenBase is composed of several microservices coordinated via Docker Compose:
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
graph TD
|
|
20
|
+
User([User/Developer]) --> Dashboard[React Dashboard :3001]
|
|
21
|
+
Dashboard --> Backend[FastAPI Backend :3003]
|
|
22
|
+
Backend --> Postgres[PostgreSQL :5433]
|
|
23
|
+
Backend --> MinIO[MinIO Storage :9002/9003]
|
|
24
|
+
Backend --> Docker[Docker Socket]
|
|
25
|
+
|
|
26
|
+
App[Your Application] --> SDK[OpenBase SDK]
|
|
27
|
+
SDK --> Backend
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Services Breakdown:
|
|
31
|
+
- **Dashboard**: React-based UI for project management.
|
|
32
|
+
- **Backend**: FastAPI service handling project creation, database queries, storage management, and log streaming.
|
|
33
|
+
- **PostgreSQL**: Stores both metadata and project-specific data.
|
|
34
|
+
- **MinIO**: S3-compatible object storage.
|
|
35
|
+
|
|
36
|
+
## š¦ Getting Started
|
|
37
|
+
|
|
38
|
+
### Prerequisites
|
|
39
|
+
- [Docker](https://www.docker.com/get-started) and [Docker Compose](https://docs.docker.com/compose/install/)
|
|
40
|
+
|
|
41
|
+
### Local Setup with Docker (Recommended)
|
|
42
|
+
|
|
43
|
+
1. **Clone the repository**:
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/omkar1344patil/openbase.git
|
|
46
|
+
cd openbase
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
2. **Configure Environment**:
|
|
50
|
+
Copy the example environment file (if available) or create a `.env` in the root with your configuration.
|
|
51
|
+
```bash
|
|
52
|
+
cp .env.example .env
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
3. **Start the services**:
|
|
56
|
+
```bash
|
|
57
|
+
docker compose up --build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
4. **Access the Dashboard**:
|
|
61
|
+
Open [http://localhost:3001](http://localhost:3001) in your browser.
|
|
62
|
+
|
|
63
|
+
### Development Setup
|
|
64
|
+
|
|
65
|
+
If you wish to run services individually:
|
|
66
|
+
|
|
67
|
+
- **Backend**:
|
|
68
|
+
```bash
|
|
69
|
+
cd dashboard-backend
|
|
70
|
+
pip install -r requirements.txt
|
|
71
|
+
python main.py
|
|
72
|
+
```
|
|
73
|
+
- **Dashboard**:
|
|
74
|
+
```bash
|
|
75
|
+
cd dashboard
|
|
76
|
+
npm install
|
|
77
|
+
npm start
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## āļø Environment Variables
|
|
81
|
+
|
|
82
|
+
Critical environment variables required for the project (defined in `.env`):
|
|
83
|
+
|
|
84
|
+
| Variable | Description | Default |
|
|
85
|
+
|----------|-------------|---------|
|
|
86
|
+
| `POSTGRES_USER` | Admin username for PostgreSQL | `postgres` |
|
|
87
|
+
| `POSTGRES_PASSWORD` | Admin password for PostgreSQL | - |
|
|
88
|
+
| `MINIO_ROOT_USER` | Admin username for MinIO | `minioadmin` |
|
|
89
|
+
| `MINIO_ROOT_PASSWORD` | Admin password for MinIO | `minioadmin` |
|
|
90
|
+
| `ANON_KEY` | Public API key for projects | - |
|
|
91
|
+
| `SERVICE_KEY` | Admin service key | - |
|
|
92
|
+
|
|
93
|
+
## š§ Context for AI Agents
|
|
94
|
+
|
|
95
|
+
OpenBase is designed with a specific philosophy:
|
|
96
|
+
- **PostgREST-like Patterns**: While not using PostgREST directly for all queries, the API follows similar conventions for database interaction.
|
|
97
|
+
- **Project Isolation**: Each project created through the dashboard results in a dedicated PostgreSQL database and a MinIO bucket.
|
|
98
|
+
- **Unified Gateway**: The FastAPI backend acts as the single point of entry for both the dashboard and external applications using the SDK.
|
|
99
|
+
|
|
100
|
+
### Common Development Tasks:
|
|
101
|
+
- **Adding SDK Operators**: Update the `openbase-js` client logic to support new SQL operators (e.g., `.gt()`, `.like()`).
|
|
102
|
+
- **Extending Monitoring**: Modify `Monitoring.tsx` in the frontend and the corresponding `/monitoring` endpoints in the backend.
|
|
103
|
+
- **Storage Fixes**: Ensure `MINIO_PUBLIC_HOST` is correctly handled for pre-signed URLs to work across both Docker and local environments.
|
|
104
|
+
|
|
105
|
+
## š ļø Tech Stack
|
|
106
|
+
|
|
107
|
+
- **Frontend**: React, TypeScript, Tailwind CSS, Lucide Icons, Recharts.
|
|
108
|
+
- **Backend**: Python, FastAPI, Motor (PyMongo - if applicable), Miniopy-async, Psycopg2.
|
|
109
|
+
- **Storage**: MinIO (S3-compatible).
|
|
110
|
+
- **Database**: PostgreSQL 16.
|
|
111
|
+
- **Orchestration**: Docker Compose.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
Built with ā¤ļø by [Omkar Patil](https://github.com/omkar1344patil)
|
package/package.json
CHANGED
package/test.js
CHANGED
|
@@ -1,97 +1,115 @@
|
|
|
1
1
|
const { createClient } = require('./index');
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Openbase Dual-Key Test
|
|
5
|
+
*
|
|
6
|
+
* 1. Authorization Key (JWT or Service Key): Pass as 2nd argument.
|
|
7
|
+
* 2. Identity Key (Anon Key): Pass in the options object.
|
|
8
|
+
*/
|
|
3
9
|
const client = createClient(
|
|
4
|
-
'http://localhost:
|
|
5
|
-
|
|
6
|
-
'
|
|
10
|
+
'http://localhost:3003',
|
|
11
|
+
// In production, this would be your secret JWT
|
|
12
|
+
'eHUJV8pFtbCh76xiUALc1YMWslyN8QxZgYG5RKySsgE',
|
|
13
|
+
'mydb',
|
|
14
|
+
{
|
|
15
|
+
// This identifies your project to the backend
|
|
16
|
+
anonKey: 'Yur7U6O5DHzC7Sjtyx_tI7EYM6lpMBO_I9vWWrN4YdQ'
|
|
17
|
+
}
|
|
7
18
|
);
|
|
8
19
|
|
|
20
|
+
// Existing employees: IDs 101ā104, salaries 55000ā80000
|
|
21
|
+
|
|
9
22
|
async function test() {
|
|
10
23
|
console.log('\nāā SELECT all āāāāāāāāāāāāāāāāāāāāāā');
|
|
11
|
-
const { data, error } = await client.from('
|
|
24
|
+
const { data, error } = await client.from('employees').select('*');
|
|
12
25
|
console.log('data:', data);
|
|
13
26
|
console.log('error:', error);
|
|
14
27
|
|
|
15
28
|
console.log('\nāā SELECT with .eq āāāāāāāāāāāāāāāāā');
|
|
16
29
|
const { data: single } = await client
|
|
17
|
-
.from('
|
|
18
|
-
.select('
|
|
19
|
-
.eq('
|
|
30
|
+
.from('employees')
|
|
31
|
+
.select('firstname, lastname')
|
|
32
|
+
.eq('employeeid', 101)
|
|
20
33
|
.single();
|
|
21
34
|
console.log('single:', single);
|
|
22
35
|
|
|
23
36
|
console.log('\nāā INSERT āāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
24
|
-
const { data: inserted } = await client.from('
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
const { data: inserted, error: insertErr } = await client.from('employees').insert({
|
|
38
|
+
employeeid: 999,
|
|
39
|
+
firstname: 'Test',
|
|
40
|
+
lastname: 'User',
|
|
41
|
+
hiredate: '2024-01-01',
|
|
42
|
+
salary: 50000,
|
|
28
43
|
});
|
|
29
44
|
console.log('inserted:', inserted);
|
|
45
|
+
console.log('insertErr:', insertErr);
|
|
30
46
|
|
|
31
47
|
console.log('\nāā UPDATE āāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
32
48
|
const { data: updated } = await client
|
|
33
|
-
.from('
|
|
34
|
-
.update({
|
|
35
|
-
.eq('
|
|
49
|
+
.from('employees')
|
|
50
|
+
.update({ salary: 60000 })
|
|
51
|
+
.eq('employeeid', 999);
|
|
36
52
|
console.log('updated:', updated);
|
|
37
53
|
|
|
38
54
|
console.log('\nāā DELETE āāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
39
55
|
const { data: deleted } = await client
|
|
40
|
-
.from('
|
|
56
|
+
.from('employees')
|
|
41
57
|
.delete()
|
|
42
|
-
.eq('
|
|
58
|
+
.eq('employeeid', 999);
|
|
43
59
|
console.log('deleted:', deleted);
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
async function testOperators() {
|
|
47
63
|
console.log('\nāā GT / LT āāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
48
|
-
const { data: gt } = await client.from('
|
|
49
|
-
console.log('gt
|
|
64
|
+
const { data: gt } = await client.from('employees').select('*').gt('salary', 60000);
|
|
65
|
+
console.log('gt salary > 60000:', gt?.map(r => `${r.firstname} (${r.salary})`));
|
|
50
66
|
|
|
51
67
|
console.log('\nāā GTE / LTE āāāāāāāāāāāāāāāāāāāāāāā');
|
|
52
|
-
const { data: lte } = await client.from('
|
|
53
|
-
console.log('lte
|
|
68
|
+
const { data: lte } = await client.from('employees').select('*').lte('salary', 65000);
|
|
69
|
+
console.log('lte salary <= 65000:', lte?.map(r => `${r.firstname} (${r.salary})`));
|
|
54
70
|
|
|
55
71
|
console.log('\nāā LIKE āāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
56
|
-
const { data: like } = await client.from('
|
|
57
|
-
console.log('like
|
|
72
|
+
const { data: like } = await client.from('employees').select('*').like('firstname', '%a%');
|
|
73
|
+
console.log('like firstname %a%:', like?.map(r => r.firstname));
|
|
58
74
|
|
|
59
75
|
console.log('\nāā ILIKE (case insensitive) āāāāāāāā');
|
|
60
|
-
const { data: ilike } = await client.from('
|
|
61
|
-
console.log('ilike
|
|
76
|
+
const { data: ilike } = await client.from('employees').select('*').ilike('lastname', '%s%');
|
|
77
|
+
console.log('ilike lastname %s%:', ilike?.map(r => r.lastname));
|
|
62
78
|
|
|
63
79
|
console.log('\nāā IN āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
64
|
-
const { data: inResult } = await client.from('
|
|
65
|
-
console.log('in
|
|
80
|
+
const { data: inResult } = await client.from('employees').select('*').in('employeeid', [101, 102]);
|
|
81
|
+
console.log('in employeeid [101, 102]:', inResult?.map(r => r.firstname));
|
|
66
82
|
|
|
67
83
|
console.log('\nāā IS NULL āāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
68
|
-
const { data: isNull } = await client.from('
|
|
69
|
-
console.log('is
|
|
84
|
+
const { data: isNull } = await client.from('employees').select('*').is('hiredate', null);
|
|
85
|
+
console.log('is hiredate null:', isNull);
|
|
70
86
|
|
|
71
87
|
console.log('\nāā ORDER ASC āāāāāāāāāāāāāāāāāāāāāāā');
|
|
72
|
-
const { data: asc } = await client.from('
|
|
73
|
-
console.log('order
|
|
88
|
+
const { data: asc } = await client.from('employees').select('*').order('salary', { ascending: true });
|
|
89
|
+
console.log('order salary asc:', asc?.map(r => r.salary));
|
|
74
90
|
|
|
75
91
|
console.log('\nāā ORDER DESC āāāāāāāāāāāāāāāāāāāāāā');
|
|
76
|
-
const { data: desc } = await client.from('
|
|
77
|
-
console.log('order
|
|
92
|
+
const { data: desc } = await client.from('employees').select('*').order('salary', { ascending: false });
|
|
93
|
+
console.log('order salary desc:', desc?.map(r => r.salary));
|
|
78
94
|
|
|
79
95
|
console.log('\nāā LIMIT āāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
80
|
-
const { data: limited } = await client.from('
|
|
96
|
+
const { data: limited } = await client.from('employees').select('*').limit(2);
|
|
81
97
|
console.log('limit 2:', limited?.length, 'rows');
|
|
82
98
|
|
|
83
99
|
console.log('\nāā RANGE (pagination) āāāāāāāāāāāāāā');
|
|
84
|
-
const { data: page } = await client.from('
|
|
100
|
+
const { data: page } = await client.from('employees').select('*').range(0, 1);
|
|
85
101
|
console.log('range 0-1:', page?.length, 'rows');
|
|
86
102
|
|
|
87
103
|
console.log('\nāā CHAINED (gt + order + limit) āāāā');
|
|
88
104
|
const { data: chained } = await client
|
|
89
|
-
.from('
|
|
105
|
+
.from('employees')
|
|
90
106
|
.select('*')
|
|
91
|
-
.gt('
|
|
92
|
-
.order('
|
|
107
|
+
.gt('salary', 55000)
|
|
108
|
+
.order('salary', { ascending: false })
|
|
93
109
|
.limit(3);
|
|
94
|
-
console.log('chained:', chained);
|
|
110
|
+
console.log('chained:', chained?.map(r => `${r.firstname} (${r.salary})`));
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
|
|
113
|
+
test()
|
|
114
|
+
.then(() => testOperators())
|
|
115
|
+
.catch(console.error);
|