@luxdb/sdk 1.4.3 → 2.1.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 +76 -18
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/project.js +170 -23
- package/dist/cjs/table.js +83 -24
- package/dist/esm/index.js +1 -0
- package/dist/esm/project.js +170 -23
- package/dist/esm/table.js +83 -24
- package/dist/types/index.d.ts +2 -1
- package/dist/types/project.d.ts +47 -8
- package/dist/types/table.d.ts +14 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,45 +80,102 @@ const { data: matches } = await lux
|
|
|
80
80
|
.near("embedding", queryEmbedding, { k: 10, threshold: 0.8 });
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
Writes return the affected row(s), including server-generated columns (`id`,
|
|
84
|
+
UUIDv7 primary keys, `DEFAULT now()` timestamps):
|
|
85
|
+
|
|
83
86
|
```ts
|
|
87
|
+
// insert -> the inserted row
|
|
84
88
|
const { data: inserted, error: insertError } = await lux
|
|
85
89
|
.table("messages")
|
|
86
90
|
.insert({ body: "hello", channel: "general" });
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
// bulk insert in a single request -> array of rows
|
|
93
|
+
const { data: many } = await lux
|
|
94
|
+
.table("messages")
|
|
95
|
+
.insert([{ body: "a" }, { body: "b" }]);
|
|
96
|
+
|
|
97
|
+
// upsert: insert, or update the row that conflicts on `onConflict` (default: PK)
|
|
98
|
+
const { data: user } = await lux
|
|
99
|
+
.table("users")
|
|
100
|
+
.upsert({ email: "a@x.com", name: "Bob" }, { onConflict: "email" });
|
|
101
|
+
|
|
102
|
+
// update / delete -> the affected rows
|
|
103
|
+
const { data: updated } = await lux
|
|
89
104
|
.table("messages")
|
|
90
105
|
.update({ body: "edited" })
|
|
91
106
|
.eq("id", inserted?.id);
|
|
92
107
|
|
|
93
|
-
const { data: deleted
|
|
108
|
+
const { data: deleted } = await lux
|
|
94
109
|
.table("messages")
|
|
95
110
|
.delete()
|
|
96
111
|
.eq("id", inserted?.id);
|
|
97
112
|
```
|
|
98
113
|
|
|
114
|
+
### Filters and JSON
|
|
115
|
+
|
|
116
|
+
Beyond `.eq/.neq/.gt/.gte/.lt/.lte`, the query builder supports `IN` lists, JSON
|
|
117
|
+
dot-paths, and arrays:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
await lux.table("users").select().in("id", [1, 2, 3]);
|
|
121
|
+
await lux.table("users").select().notIn("status", ["banned", "deleted"]);
|
|
122
|
+
|
|
123
|
+
// JSON columns round-trip as native objects (no manual JSON.stringify)
|
|
124
|
+
await lux.table("events").insert({ metadata: { plan: { tier: "pro" }, count: 0 } });
|
|
125
|
+
|
|
126
|
+
// Query JSON by dot-path, like a JS object. A path that does not resolve is a
|
|
127
|
+
// non-match, never an error.
|
|
128
|
+
await lux.table("events").select().eq("metadata.plan.tier", "pro");
|
|
129
|
+
|
|
130
|
+
// IS VALID is existence, not truthiness: 0 / false / "" all count as valid.
|
|
131
|
+
await lux.table("events").select().isValid("metadata.count");
|
|
132
|
+
await lux.table("events").select().isNotValid("metadata.deleted_at");
|
|
133
|
+
|
|
134
|
+
// IS NULL / IS NOT NULL on a regular column (NULL == the column is absent)
|
|
135
|
+
await lux.table("tasks").select().isNull("deleted_at");
|
|
136
|
+
await lux.table("tasks").select().isNotNull("archived_at");
|
|
137
|
+
|
|
138
|
+
// Array membership, and a declared JSON-path index for range queries at scale.
|
|
139
|
+
await lux.table("events").select().contains("tags", "urgent");
|
|
140
|
+
await lux.table("events").createIndex("metadata.plan.tier", "str");
|
|
141
|
+
```
|
|
142
|
+
|
|
99
143
|
## Live tables
|
|
100
144
|
|
|
101
145
|
Browser clients can subscribe to table queries over Lux Live. The SDK opens a WebSocket to the project live endpoint, and Lux core sends a snapshot followed by insert/update/delete events for rows matching the query.
|
|
102
146
|
|
|
147
|
+
`.live()` resolves once the server confirms the subscription, returning the same
|
|
148
|
+
`{ data, error }` shape as the rest of the SDK (here named `{ live, error }`). If
|
|
149
|
+
the query isn't permitted by a read grant, `error` is populated and `live` is
|
|
150
|
+
`null`. The subscription is async-iterable: the buffered snapshot arrives first,
|
|
151
|
+
then live changes.
|
|
152
|
+
|
|
103
153
|
```ts
|
|
104
|
-
const
|
|
154
|
+
const { live, error } = await lux
|
|
105
155
|
.table<{ id: string; channel_id: string; body: string }>("messages")
|
|
106
156
|
.eq("channel_id", "general")
|
|
107
|
-
.live()
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
.live();
|
|
158
|
+
|
|
159
|
+
if (error) throw error;
|
|
160
|
+
|
|
161
|
+
for await (const event of live) {
|
|
162
|
+
if (event.type === "snapshot") console.log(event.rows);
|
|
163
|
+
else console.log(event.type, event.new ?? event.old);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
You can also attach callbacks instead of iterating:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const { live, error } = await lux.table("messages").eq("channel_id", "general").live();
|
|
171
|
+
if (error) throw error;
|
|
172
|
+
|
|
173
|
+
live
|
|
174
|
+
.on("insert", (event) => console.log(event.new))
|
|
175
|
+
.on("update", (event) => console.log(event.old, event.new))
|
|
176
|
+
.on("delete", (event) => console.log(event.old));
|
|
177
|
+
|
|
178
|
+
await live.unsubscribe();
|
|
122
179
|
```
|
|
123
180
|
|
|
124
181
|
## OAuth
|
|
@@ -203,3 +260,4 @@ const value = await lux.get("hello");
|
|
|
203
260
|
- Browser live subscriptions use the project publishable key plus the signed-in user's JWT.
|
|
204
261
|
- Table `select()` accepts Lux's constrained projection grammar, not arbitrary SQL.
|
|
205
262
|
- Direct `lux://` or `rediss://` database access uses the database password and is for trusted infrastructure.
|
|
263
|
+
- With auth enabled, signed-in users are denied by default and gated by per-table **grants** (`GRANT read, write ON t WHERE user_id = auth.uid()`). Reads, writes, and `.live()` are all checked against the grant: a query or subscription must satisfy the predicate or it is rejected (an unscoped `.live()` under a row-scoped grant fails at subscribe time). Grants are authored as migrations.
|
package/dist/cjs/index.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Lux = exports.TableSubscription = exports.TableQueryBuilder = exports.createServerClient = exports.createBrowserClient = exports.LuxProjectClient = exports.createProjectClient = void 0;
|
|
6
|
+
exports.Lux = exports.TableSubscription = exports.TableQueryBuilder = exports.createServerClient = exports.createBrowserClient = exports.LuxProjectLiveSubscription = exports.LuxProjectClient = exports.createProjectClient = void 0;
|
|
7
7
|
exports.createClient = createClient;
|
|
8
8
|
exports.createAuthClient = createAuthClient;
|
|
9
9
|
const ioredis_1 = __importDefault(require("ioredis"));
|
|
@@ -14,6 +14,8 @@ Object.defineProperty(exports, "LuxProjectClient", { enumerable: true, get: func
|
|
|
14
14
|
const namespaces_1 = require("./namespaces");
|
|
15
15
|
const realtime_1 = require("./realtime");
|
|
16
16
|
const table_1 = require("./table");
|
|
17
|
+
var project_2 = require("./project");
|
|
18
|
+
Object.defineProperty(exports, "LuxProjectLiveSubscription", { enumerable: true, get: function () { return project_2.LuxProjectLiveSubscription; } });
|
|
17
19
|
var browser_1 = require("./browser");
|
|
18
20
|
Object.defineProperty(exports, "createBrowserClient", { enumerable: true, get: function () { return browser_1.createBrowserClient; } });
|
|
19
21
|
var ssr_1 = require("./ssr");
|
package/dist/cjs/project.js
CHANGED
|
@@ -218,6 +218,12 @@ class LuxProjectTable {
|
|
|
218
218
|
insert(rowOrRows) {
|
|
219
219
|
return new LuxProjectInsertBuilder(this.client, this.name, rowOrRows);
|
|
220
220
|
}
|
|
221
|
+
upsert(rowOrRows, options) {
|
|
222
|
+
return new LuxProjectInsertBuilder(this.client, this.name, rowOrRows, {
|
|
223
|
+
upsert: true,
|
|
224
|
+
onConflict: options?.onConflict,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
221
227
|
update(patch) {
|
|
222
228
|
return new LuxProjectMutationBuilder(this.client, this.name, 'PATCH', patch);
|
|
223
229
|
}
|
|
@@ -272,8 +278,32 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
|
|
|
272
278
|
return this.addFilter(column, 'lte', value);
|
|
273
279
|
}
|
|
274
280
|
is(column, value) {
|
|
281
|
+
// `.is(col, null)` is the Supabase-style spelling of an IS NULL check.
|
|
282
|
+
if (value === null)
|
|
283
|
+
return this.addFilter(column, 'isNull', '');
|
|
275
284
|
return this.addFilter(column, 'is', value);
|
|
276
285
|
}
|
|
286
|
+
isNull(column) {
|
|
287
|
+
return this.addFilter(column, 'isNull', '');
|
|
288
|
+
}
|
|
289
|
+
isNotNull(column) {
|
|
290
|
+
return this.addFilter(column, 'isNotNull', '');
|
|
291
|
+
}
|
|
292
|
+
in(column, values) {
|
|
293
|
+
return this.addFilter(column, 'in', values);
|
|
294
|
+
}
|
|
295
|
+
notIn(column, values) {
|
|
296
|
+
return this.addFilter(column, 'notIn', values);
|
|
297
|
+
}
|
|
298
|
+
isValid(column) {
|
|
299
|
+
return this.addFilter(column, 'isValid', '');
|
|
300
|
+
}
|
|
301
|
+
isNotValid(column) {
|
|
302
|
+
return this.addFilter(column, 'isNotValid', '');
|
|
303
|
+
}
|
|
304
|
+
contains(column, value) {
|
|
305
|
+
return this.addFilter(column, 'contains', value);
|
|
306
|
+
}
|
|
277
307
|
join(table, alias, onLeft, onRight) {
|
|
278
308
|
this.joins.push({ type: 'inner', table, alias, onLeft, onRight });
|
|
279
309
|
return this;
|
|
@@ -377,8 +407,14 @@ class LuxProjectSelectBuilder extends LuxProjectFilterBuilder {
|
|
|
377
407
|
}
|
|
378
408
|
return (0, utils_1.ok)(rows[0]);
|
|
379
409
|
}
|
|
380
|
-
live() {
|
|
381
|
-
|
|
410
|
+
async live() {
|
|
411
|
+
const live = new LuxProjectLiveSubscription(this.client, this.tableName, this.columns, this.filters, this.nearQuery, this.orderBy, this.limitCount, this.offsetCount);
|
|
412
|
+
const error = await live.start();
|
|
413
|
+
if (error) {
|
|
414
|
+
await live.unsubscribe();
|
|
415
|
+
return { live: null, error };
|
|
416
|
+
}
|
|
417
|
+
return { live, error: null };
|
|
382
418
|
}
|
|
383
419
|
}
|
|
384
420
|
exports.LuxProjectSelectBuilder = LuxProjectSelectBuilder;
|
|
@@ -401,7 +437,11 @@ class LuxProjectLiveSubscription {
|
|
|
401
437
|
change: [],
|
|
402
438
|
};
|
|
403
439
|
this.unsubscribeFn = null;
|
|
404
|
-
|
|
440
|
+
// Async-iterator plumbing: events buffer in `queue` until a `for await`
|
|
441
|
+
// consumer pulls them; pending `next()` calls park in `waiters`.
|
|
442
|
+
this.queue = [];
|
|
443
|
+
this.waiters = [];
|
|
444
|
+
this.closed = false;
|
|
405
445
|
}
|
|
406
446
|
on(type, handler) {
|
|
407
447
|
this.handlers[type].push(handler);
|
|
@@ -410,15 +450,76 @@ class LuxProjectLiveSubscription {
|
|
|
410
450
|
async unsubscribe() {
|
|
411
451
|
this.unsubscribeFn?.();
|
|
412
452
|
this.unsubscribeFn = null;
|
|
413
|
-
|
|
453
|
+
this.close();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Open the subscription and wait for the server to confirm it. Resolves
|
|
457
|
+
* `null` once the initial snapshot arrives, or a `LuxError` if the
|
|
458
|
+
* subscription is rejected (e.g. a grant `FORBIDDEN`) or the socket fails.
|
|
459
|
+
* Subsequent errors after a successful start surface via `on('error')` and
|
|
460
|
+
* end the async iterator.
|
|
461
|
+
*/
|
|
414
462
|
async start() {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
463
|
+
let settled = false;
|
|
464
|
+
let settle;
|
|
465
|
+
const ready = new Promise((resolve) => {
|
|
466
|
+
settle = (error) => {
|
|
467
|
+
if (settled)
|
|
468
|
+
return;
|
|
469
|
+
settled = true;
|
|
470
|
+
resolve(error);
|
|
471
|
+
};
|
|
472
|
+
});
|
|
473
|
+
// Safety net: a server that never answers shouldn't hang the caller.
|
|
474
|
+
const timeout = setTimeout(() => {
|
|
475
|
+
settle({ code: 'LIVE_TIMEOUT', message: 'Timed out establishing live subscription' });
|
|
476
|
+
}, 15000);
|
|
477
|
+
this.unsubscribeFn = await this.client._subscribeLive(this.spec(), (event) => {
|
|
478
|
+
const kind = event?.kind;
|
|
479
|
+
this.handleEvent(event);
|
|
480
|
+
if (kind === 'snapshot')
|
|
481
|
+
settle(null);
|
|
482
|
+
}, (error) => {
|
|
483
|
+
const luxError = {
|
|
484
|
+
code: error.code ?? 'LIVE_ERROR',
|
|
485
|
+
message: error.message ?? 'Live subscription failed',
|
|
486
|
+
};
|
|
487
|
+
if (settled) {
|
|
488
|
+
// Post-start failure: notify handlers and end the stream.
|
|
489
|
+
this.emit({ type: 'error', table: this.table, new: null, old: null, error });
|
|
490
|
+
this.close();
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
settle(luxError);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
const result = await ready;
|
|
497
|
+
clearTimeout(timeout);
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
[Symbol.asyncIterator]() {
|
|
501
|
+
return {
|
|
502
|
+
next: () => {
|
|
503
|
+
const buffered = this.queue.shift();
|
|
504
|
+
if (buffered)
|
|
505
|
+
return Promise.resolve({ value: buffered, done: false });
|
|
506
|
+
if (this.closed)
|
|
507
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
508
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
509
|
+
},
|
|
510
|
+
return: () => {
|
|
511
|
+
void this.unsubscribe();
|
|
512
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
close() {
|
|
517
|
+
if (this.closed)
|
|
518
|
+
return;
|
|
519
|
+
this.closed = true;
|
|
520
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
521
|
+
waiter({ value: undefined, done: true });
|
|
522
|
+
}
|
|
422
523
|
}
|
|
423
524
|
spec() {
|
|
424
525
|
const spec = {
|
|
@@ -487,28 +588,46 @@ class LuxProjectLiveSubscription {
|
|
|
487
588
|
for (const handler of this.handlers.change)
|
|
488
589
|
handler(event);
|
|
489
590
|
}
|
|
591
|
+
// Feed `for await` consumers the data events (errors end the stream via close()).
|
|
592
|
+
if (event.type !== 'error')
|
|
593
|
+
this.pushIterator(event);
|
|
594
|
+
}
|
|
595
|
+
pushIterator(event) {
|
|
596
|
+
if (this.closed)
|
|
597
|
+
return;
|
|
598
|
+
const waiter = this.waiters.shift();
|
|
599
|
+
if (waiter)
|
|
600
|
+
waiter({ value: event, done: false });
|
|
601
|
+
else
|
|
602
|
+
this.queue.push(event);
|
|
490
603
|
}
|
|
491
604
|
}
|
|
492
605
|
exports.LuxProjectLiveSubscription = LuxProjectLiveSubscription;
|
|
493
606
|
class LuxProjectInsertBuilder extends LuxProjectThenable {
|
|
494
|
-
constructor(client, tableName, rowOrRows) {
|
|
607
|
+
constructor(client, tableName, rowOrRows, upsertOptions) {
|
|
495
608
|
super();
|
|
496
609
|
this.client = client;
|
|
497
610
|
this.tableName = tableName;
|
|
498
611
|
this.rowOrRows = rowOrRows;
|
|
612
|
+
this.upsertOptions = upsertOptions;
|
|
499
613
|
}
|
|
500
614
|
async execute() {
|
|
501
|
-
|
|
502
|
-
|
|
615
|
+
// One request for both shapes: an array body inserts all rows server-side
|
|
616
|
+
// in a single round-trip. The server returns the affected row(s)
|
|
617
|
+
// ({result: row} for a single row, {result: [rows]} for an array).
|
|
618
|
+
let path = `/tables/${encodeURIComponent(this.tableName)}`;
|
|
619
|
+
if (this.upsertOptions?.upsert) {
|
|
620
|
+
const params = new URLSearchParams();
|
|
621
|
+
if (this.upsertOptions.onConflict)
|
|
622
|
+
params.set('on_conflict', this.upsertOptions.onConflict);
|
|
623
|
+
else
|
|
624
|
+
params.set('upsert', 'true');
|
|
625
|
+
path += `?${params.toString()}`;
|
|
503
626
|
}
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return result;
|
|
509
|
-
results.push(result.data);
|
|
510
|
-
}
|
|
511
|
-
return (0, utils_1.ok)(results);
|
|
627
|
+
const res = await this.client.request('POST', path, this.rowOrRows);
|
|
628
|
+
if (res.error)
|
|
629
|
+
return res;
|
|
630
|
+
return (0, utils_1.ok)(unwrapResult(res.data));
|
|
512
631
|
}
|
|
513
632
|
}
|
|
514
633
|
exports.LuxProjectInsertBuilder = LuxProjectInsertBuilder;
|
|
@@ -524,7 +643,11 @@ class LuxProjectMutationBuilder extends LuxProjectFilterBuilder {
|
|
|
524
643
|
}
|
|
525
644
|
const params = this.filteredQueryParams();
|
|
526
645
|
const query = params.toString();
|
|
527
|
-
return
|
|
646
|
+
// Update/delete return the affected rows ({result: [rows]}); unwrap them.
|
|
647
|
+
const res = await this.client.request(this.method, `/tables/${encodeURIComponent(this.tableName)}${query ? `?${query}` : ''}`, this.body);
|
|
648
|
+
if (res.error)
|
|
649
|
+
return res;
|
|
650
|
+
return (0, utils_1.ok)(unwrapResult(res.data));
|
|
528
651
|
}
|
|
529
652
|
}
|
|
530
653
|
exports.LuxProjectMutationBuilder = LuxProjectMutationBuilder;
|
|
@@ -548,6 +671,16 @@ function normalizeWhere(where) {
|
|
|
548
671
|
function filtersToWhere(filters) {
|
|
549
672
|
return filters.map((filter) => {
|
|
550
673
|
const op = filterOperatorToWhere(filter.operator);
|
|
674
|
+
if (filter.operator === 'in' || filter.operator === 'notIn') {
|
|
675
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
676
|
+
return normalizeWhere(`${filter.column} ${op} ( ${values.map(formatWhereValue).join(' ')} )`);
|
|
677
|
+
}
|
|
678
|
+
if (filter.operator === 'isValid' ||
|
|
679
|
+
filter.operator === 'isNotValid' ||
|
|
680
|
+
filter.operator === 'isNull' ||
|
|
681
|
+
filter.operator === 'isNotNull') {
|
|
682
|
+
return normalizeWhere(`${filter.column} ${op}`);
|
|
683
|
+
}
|
|
551
684
|
return normalizeWhere(`${filter.column} ${op} ${formatWhereValue(filter.value)}`);
|
|
552
685
|
}).join(' AND ');
|
|
553
686
|
}
|
|
@@ -572,6 +705,20 @@ function filterOperatorToWhere(operator) {
|
|
|
572
705
|
return '<';
|
|
573
706
|
case 'lte':
|
|
574
707
|
return '<=';
|
|
708
|
+
case 'in':
|
|
709
|
+
return 'IN';
|
|
710
|
+
case 'notIn':
|
|
711
|
+
return 'NOT IN';
|
|
712
|
+
case 'isValid':
|
|
713
|
+
return 'IS VALID';
|
|
714
|
+
case 'isNotValid':
|
|
715
|
+
return 'IS NOT VALID';
|
|
716
|
+
case 'isNull':
|
|
717
|
+
return 'IS NULL';
|
|
718
|
+
case 'isNotNull':
|
|
719
|
+
return 'IS NOT NULL';
|
|
720
|
+
case 'contains':
|
|
721
|
+
return 'CONTAINS';
|
|
575
722
|
}
|
|
576
723
|
}
|
|
577
724
|
function formatWhereValue(value) {
|
package/dist/cjs/table.js
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.TableQueryBuilder = exports.TableSubscription = void 0;
|
|
4
4
|
const utils_1 = require("./utils");
|
|
5
|
+
/** Serialize a field value: JSON objects/arrays round-trip as JSON text. */
|
|
6
|
+
function serializeFieldValue(v) {
|
|
7
|
+
if (v !== null && typeof v === 'object') {
|
|
8
|
+
return JSON.stringify(v);
|
|
9
|
+
}
|
|
10
|
+
return String(v);
|
|
11
|
+
}
|
|
12
|
+
/** Serialize one WHERE condition into RESP tokens. */
|
|
13
|
+
function serializeCondition(cond) {
|
|
14
|
+
if (cond.op === 'IN' || cond.op === 'NOT IN') {
|
|
15
|
+
const values = Array.isArray(cond.value) ? cond.value : [cond.value];
|
|
16
|
+
return [
|
|
17
|
+
cond.field,
|
|
18
|
+
...(cond.op === 'NOT IN' ? ['NOT', 'IN'] : ['IN']),
|
|
19
|
+
'(',
|
|
20
|
+
...values.map(String),
|
|
21
|
+
')',
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
if (cond.op === 'IS VALID')
|
|
25
|
+
return [cond.field, 'IS', 'VALID'];
|
|
26
|
+
if (cond.op === 'IS NOT VALID')
|
|
27
|
+
return [cond.field, 'IS', 'NOT', 'VALID'];
|
|
28
|
+
return [cond.field, cond.op, String(cond.value)];
|
|
29
|
+
}
|
|
30
|
+
/** Join serialized conditions with AND separators. */
|
|
31
|
+
function serializeConditions(conditions) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
34
|
+
out.push(...serializeCondition(conditions[i]));
|
|
35
|
+
if (i < conditions.length - 1)
|
|
36
|
+
out.push('AND');
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
5
40
|
class TableSubscription {
|
|
6
41
|
constructor(client, table, selectArgsBuilder, initError = null) {
|
|
7
42
|
this.handlers = {
|
|
@@ -165,14 +200,7 @@ class TableQueryBuilder {
|
|
|
165
200
|
args.push(...(this.joinClause.type === 'LEFT' ? ['LEFT', 'JOIN'] : ['JOIN']), this.joinClause.table, this.joinClause.alias, 'ON', this.joinClause.onLeft, '=', this.joinClause.onRight);
|
|
166
201
|
}
|
|
167
202
|
if (allConditions.length) {
|
|
168
|
-
args.push('WHERE');
|
|
169
|
-
for (let i = 0; i < allConditions.length; i++) {
|
|
170
|
-
const cond = allConditions[i];
|
|
171
|
-
args.push(cond.field, cond.op, String(cond.value));
|
|
172
|
-
if (i < allConditions.length - 1) {
|
|
173
|
-
args.push('AND');
|
|
174
|
-
}
|
|
175
|
-
}
|
|
203
|
+
args.push('WHERE', ...serializeConditions(allConditions));
|
|
176
204
|
}
|
|
177
205
|
if (this.groupFields.length) {
|
|
178
206
|
args.push('GROUP', 'BY', ...this.groupFields);
|
|
@@ -237,6 +265,29 @@ class TableQueryBuilder {
|
|
|
237
265
|
lte(field, value) {
|
|
238
266
|
return this.where(field, '<=', value);
|
|
239
267
|
}
|
|
268
|
+
in(field, values) {
|
|
269
|
+
this.conditions.push({ field, op: 'IN', value: values });
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
notIn(field, values) {
|
|
273
|
+
this.conditions.push({ field, op: 'NOT IN', value: values });
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
/** Match rows where a JSON dot-path resolves to a present, non-null value. */
|
|
277
|
+
isValid(field) {
|
|
278
|
+
this.conditions.push({ field, op: 'IS VALID', value: '' });
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
/** Match rows where a JSON dot-path is absent or resolves to null. */
|
|
282
|
+
isNotValid(field) {
|
|
283
|
+
this.conditions.push({ field, op: 'IS NOT VALID', value: '' });
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
/** Match rows where an ARRAY column (or array-valued path) contains a value. */
|
|
287
|
+
contains(field, value) {
|
|
288
|
+
this.conditions.push({ field, op: 'CONTAINS', value });
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
240
291
|
orderBy(field, dir = 'asc') {
|
|
241
292
|
this.orderField = field;
|
|
242
293
|
this.orderDir = dir.toUpperCase();
|
|
@@ -312,7 +363,7 @@ class TableQueryBuilder {
|
|
|
312
363
|
}
|
|
313
364
|
const args = [this.name];
|
|
314
365
|
for (const [k, v] of Object.entries(data)) {
|
|
315
|
-
args.push(k,
|
|
366
|
+
args.push(k, serializeFieldValue(v));
|
|
316
367
|
}
|
|
317
368
|
const result = await this.client.call('TINSERT', ...args);
|
|
318
369
|
return (0, utils_1.ok)(parseInt(result, 10) || 0);
|
|
@@ -321,6 +372,26 @@ class TableQueryBuilder {
|
|
|
321
372
|
return (0, utils_1.err)('TINSERT_ERROR', `Failed to insert into '${this.name}'`, (0, utils_1.toLuxError)(error));
|
|
322
373
|
}
|
|
323
374
|
}
|
|
375
|
+
/** Declare a typed index on a JSON dot-path, e.g. ('meta.reactions.count', 'int'). */
|
|
376
|
+
async createIndex(path, type) {
|
|
377
|
+
try {
|
|
378
|
+
await this.client.call('TINDEX', this.name, path, type.toUpperCase());
|
|
379
|
+
return (0, utils_1.ok)(true);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
return (0, utils_1.err)('TINDEX_ERROR', `Failed to index '${path}'`, (0, utils_1.toLuxError)(error));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Drop a previously declared JSON path index. */
|
|
386
|
+
async dropIndex(path) {
|
|
387
|
+
try {
|
|
388
|
+
await this.client.call('TDROPINDEX', this.name, path);
|
|
389
|
+
return (0, utils_1.ok)(true);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
return (0, utils_1.err)('TDROPINDEX_ERROR', `Failed to drop index '${path}'`, (0, utils_1.toLuxError)(error));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
324
395
|
async update(idOrData, data) {
|
|
325
396
|
try {
|
|
326
397
|
const hasExplicitId = data !== undefined;
|
|
@@ -330,20 +401,14 @@ class TableQueryBuilder {
|
|
|
330
401
|
}
|
|
331
402
|
const args = [this.name, 'SET'];
|
|
332
403
|
for (const [k, v] of Object.entries(patch)) {
|
|
333
|
-
args.push(k,
|
|
404
|
+
args.push(k, serializeFieldValue(v));
|
|
334
405
|
}
|
|
335
406
|
args.push('WHERE');
|
|
336
407
|
if (hasExplicitId) {
|
|
337
408
|
args.push('id', '=', String(idOrData));
|
|
338
409
|
}
|
|
339
410
|
else {
|
|
340
|
-
|
|
341
|
-
const cond = this.conditions[i];
|
|
342
|
-
args.push(cond.field, cond.op, String(cond.value));
|
|
343
|
-
if (i < this.conditions.length - 1) {
|
|
344
|
-
args.push('AND');
|
|
345
|
-
}
|
|
346
|
-
}
|
|
411
|
+
args.push(...serializeConditions(this.conditions));
|
|
347
412
|
}
|
|
348
413
|
const result = await this.client.call('TUPDATE', ...args);
|
|
349
414
|
return (0, utils_1.ok)(Number(result) || 0);
|
|
@@ -359,13 +424,7 @@ class TableQueryBuilder {
|
|
|
359
424
|
return (0, utils_1.err)('MISSING_WHERE', 'delete requires at least one filter');
|
|
360
425
|
}
|
|
361
426
|
const args = ['FROM', this.name, 'WHERE'];
|
|
362
|
-
|
|
363
|
-
const cond = this.conditions[i];
|
|
364
|
-
args.push(cond.field, cond.op, String(cond.value));
|
|
365
|
-
if (i < this.conditions.length - 1) {
|
|
366
|
-
args.push('AND');
|
|
367
|
-
}
|
|
368
|
-
}
|
|
427
|
+
args.push(...serializeConditions(this.conditions));
|
|
369
428
|
const result = await this.client.call('TDELETE', ...args);
|
|
370
429
|
return (0, utils_1.ok)(Number(result) || 0);
|
|
371
430
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { TimeSeriesNamespace, VectorNamespace } from './namespaces.js';
|
|
|
5
5
|
import { LuxRealtimeManager } from './realtime.js';
|
|
6
6
|
import { TableQueryBuilder } from './table.js';
|
|
7
7
|
export { createProjectClient, LuxProjectClient, };
|
|
8
|
+
export { LuxProjectLiveSubscription } from './project.js';
|
|
8
9
|
export { createBrowserClient } from './browser.js';
|
|
9
10
|
export { createServerClient } from './ssr.js';
|
|
10
11
|
export { TableQueryBuilder, TableSubscription } from './table.js';
|
package/dist/esm/project.js
CHANGED
|
@@ -212,6 +212,12 @@ export class LuxProjectTable {
|
|
|
212
212
|
insert(rowOrRows) {
|
|
213
213
|
return new LuxProjectInsertBuilder(this.client, this.name, rowOrRows);
|
|
214
214
|
}
|
|
215
|
+
upsert(rowOrRows, options) {
|
|
216
|
+
return new LuxProjectInsertBuilder(this.client, this.name, rowOrRows, {
|
|
217
|
+
upsert: true,
|
|
218
|
+
onConflict: options?.onConflict,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
215
221
|
update(patch) {
|
|
216
222
|
return new LuxProjectMutationBuilder(this.client, this.name, 'PATCH', patch);
|
|
217
223
|
}
|
|
@@ -265,8 +271,32 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
|
|
|
265
271
|
return this.addFilter(column, 'lte', value);
|
|
266
272
|
}
|
|
267
273
|
is(column, value) {
|
|
274
|
+
// `.is(col, null)` is the Supabase-style spelling of an IS NULL check.
|
|
275
|
+
if (value === null)
|
|
276
|
+
return this.addFilter(column, 'isNull', '');
|
|
268
277
|
return this.addFilter(column, 'is', value);
|
|
269
278
|
}
|
|
279
|
+
isNull(column) {
|
|
280
|
+
return this.addFilter(column, 'isNull', '');
|
|
281
|
+
}
|
|
282
|
+
isNotNull(column) {
|
|
283
|
+
return this.addFilter(column, 'isNotNull', '');
|
|
284
|
+
}
|
|
285
|
+
in(column, values) {
|
|
286
|
+
return this.addFilter(column, 'in', values);
|
|
287
|
+
}
|
|
288
|
+
notIn(column, values) {
|
|
289
|
+
return this.addFilter(column, 'notIn', values);
|
|
290
|
+
}
|
|
291
|
+
isValid(column) {
|
|
292
|
+
return this.addFilter(column, 'isValid', '');
|
|
293
|
+
}
|
|
294
|
+
isNotValid(column) {
|
|
295
|
+
return this.addFilter(column, 'isNotValid', '');
|
|
296
|
+
}
|
|
297
|
+
contains(column, value) {
|
|
298
|
+
return this.addFilter(column, 'contains', value);
|
|
299
|
+
}
|
|
270
300
|
join(table, alias, onLeft, onRight) {
|
|
271
301
|
this.joins.push({ type: 'inner', table, alias, onLeft, onRight });
|
|
272
302
|
return this;
|
|
@@ -370,8 +400,14 @@ export class LuxProjectSelectBuilder extends LuxProjectFilterBuilder {
|
|
|
370
400
|
}
|
|
371
401
|
return ok(rows[0]);
|
|
372
402
|
}
|
|
373
|
-
live() {
|
|
374
|
-
|
|
403
|
+
async live() {
|
|
404
|
+
const live = new LuxProjectLiveSubscription(this.client, this.tableName, this.columns, this.filters, this.nearQuery, this.orderBy, this.limitCount, this.offsetCount);
|
|
405
|
+
const error = await live.start();
|
|
406
|
+
if (error) {
|
|
407
|
+
await live.unsubscribe();
|
|
408
|
+
return { live: null, error };
|
|
409
|
+
}
|
|
410
|
+
return { live, error: null };
|
|
375
411
|
}
|
|
376
412
|
}
|
|
377
413
|
export class LuxProjectLiveSubscription {
|
|
@@ -393,7 +429,11 @@ export class LuxProjectLiveSubscription {
|
|
|
393
429
|
change: [],
|
|
394
430
|
};
|
|
395
431
|
this.unsubscribeFn = null;
|
|
396
|
-
|
|
432
|
+
// Async-iterator plumbing: events buffer in `queue` until a `for await`
|
|
433
|
+
// consumer pulls them; pending `next()` calls park in `waiters`.
|
|
434
|
+
this.queue = [];
|
|
435
|
+
this.waiters = [];
|
|
436
|
+
this.closed = false;
|
|
397
437
|
}
|
|
398
438
|
on(type, handler) {
|
|
399
439
|
this.handlers[type].push(handler);
|
|
@@ -402,15 +442,76 @@ export class LuxProjectLiveSubscription {
|
|
|
402
442
|
async unsubscribe() {
|
|
403
443
|
this.unsubscribeFn?.();
|
|
404
444
|
this.unsubscribeFn = null;
|
|
405
|
-
|
|
445
|
+
this.close();
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Open the subscription and wait for the server to confirm it. Resolves
|
|
449
|
+
* `null` once the initial snapshot arrives, or a `LuxError` if the
|
|
450
|
+
* subscription is rejected (e.g. a grant `FORBIDDEN`) or the socket fails.
|
|
451
|
+
* Subsequent errors after a successful start surface via `on('error')` and
|
|
452
|
+
* end the async iterator.
|
|
453
|
+
*/
|
|
406
454
|
async start() {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
455
|
+
let settled = false;
|
|
456
|
+
let settle;
|
|
457
|
+
const ready = new Promise((resolve) => {
|
|
458
|
+
settle = (error) => {
|
|
459
|
+
if (settled)
|
|
460
|
+
return;
|
|
461
|
+
settled = true;
|
|
462
|
+
resolve(error);
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
// Safety net: a server that never answers shouldn't hang the caller.
|
|
466
|
+
const timeout = setTimeout(() => {
|
|
467
|
+
settle({ code: 'LIVE_TIMEOUT', message: 'Timed out establishing live subscription' });
|
|
468
|
+
}, 15000);
|
|
469
|
+
this.unsubscribeFn = await this.client._subscribeLive(this.spec(), (event) => {
|
|
470
|
+
const kind = event?.kind;
|
|
471
|
+
this.handleEvent(event);
|
|
472
|
+
if (kind === 'snapshot')
|
|
473
|
+
settle(null);
|
|
474
|
+
}, (error) => {
|
|
475
|
+
const luxError = {
|
|
476
|
+
code: error.code ?? 'LIVE_ERROR',
|
|
477
|
+
message: error.message ?? 'Live subscription failed',
|
|
478
|
+
};
|
|
479
|
+
if (settled) {
|
|
480
|
+
// Post-start failure: notify handlers and end the stream.
|
|
481
|
+
this.emit({ type: 'error', table: this.table, new: null, old: null, error });
|
|
482
|
+
this.close();
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
settle(luxError);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
const result = await ready;
|
|
489
|
+
clearTimeout(timeout);
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
[Symbol.asyncIterator]() {
|
|
493
|
+
return {
|
|
494
|
+
next: () => {
|
|
495
|
+
const buffered = this.queue.shift();
|
|
496
|
+
if (buffered)
|
|
497
|
+
return Promise.resolve({ value: buffered, done: false });
|
|
498
|
+
if (this.closed)
|
|
499
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
500
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
501
|
+
},
|
|
502
|
+
return: () => {
|
|
503
|
+
void this.unsubscribe();
|
|
504
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
close() {
|
|
509
|
+
if (this.closed)
|
|
510
|
+
return;
|
|
511
|
+
this.closed = true;
|
|
512
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
513
|
+
waiter({ value: undefined, done: true });
|
|
514
|
+
}
|
|
414
515
|
}
|
|
415
516
|
spec() {
|
|
416
517
|
const spec = {
|
|
@@ -479,27 +580,45 @@ export class LuxProjectLiveSubscription {
|
|
|
479
580
|
for (const handler of this.handlers.change)
|
|
480
581
|
handler(event);
|
|
481
582
|
}
|
|
583
|
+
// Feed `for await` consumers the data events (errors end the stream via close()).
|
|
584
|
+
if (event.type !== 'error')
|
|
585
|
+
this.pushIterator(event);
|
|
586
|
+
}
|
|
587
|
+
pushIterator(event) {
|
|
588
|
+
if (this.closed)
|
|
589
|
+
return;
|
|
590
|
+
const waiter = this.waiters.shift();
|
|
591
|
+
if (waiter)
|
|
592
|
+
waiter({ value: event, done: false });
|
|
593
|
+
else
|
|
594
|
+
this.queue.push(event);
|
|
482
595
|
}
|
|
483
596
|
}
|
|
484
597
|
export class LuxProjectInsertBuilder extends LuxProjectThenable {
|
|
485
|
-
constructor(client, tableName, rowOrRows) {
|
|
598
|
+
constructor(client, tableName, rowOrRows, upsertOptions) {
|
|
486
599
|
super();
|
|
487
600
|
this.client = client;
|
|
488
601
|
this.tableName = tableName;
|
|
489
602
|
this.rowOrRows = rowOrRows;
|
|
603
|
+
this.upsertOptions = upsertOptions;
|
|
490
604
|
}
|
|
491
605
|
async execute() {
|
|
492
|
-
|
|
493
|
-
|
|
606
|
+
// One request for both shapes: an array body inserts all rows server-side
|
|
607
|
+
// in a single round-trip. The server returns the affected row(s)
|
|
608
|
+
// ({result: row} for a single row, {result: [rows]} for an array).
|
|
609
|
+
let path = `/tables/${encodeURIComponent(this.tableName)}`;
|
|
610
|
+
if (this.upsertOptions?.upsert) {
|
|
611
|
+
const params = new URLSearchParams();
|
|
612
|
+
if (this.upsertOptions.onConflict)
|
|
613
|
+
params.set('on_conflict', this.upsertOptions.onConflict);
|
|
614
|
+
else
|
|
615
|
+
params.set('upsert', 'true');
|
|
616
|
+
path += `?${params.toString()}`;
|
|
494
617
|
}
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return result;
|
|
500
|
-
results.push(result.data);
|
|
501
|
-
}
|
|
502
|
-
return ok(results);
|
|
618
|
+
const res = await this.client.request('POST', path, this.rowOrRows);
|
|
619
|
+
if (res.error)
|
|
620
|
+
return res;
|
|
621
|
+
return ok(unwrapResult(res.data));
|
|
503
622
|
}
|
|
504
623
|
}
|
|
505
624
|
export class LuxProjectMutationBuilder extends LuxProjectFilterBuilder {
|
|
@@ -514,7 +633,11 @@ export class LuxProjectMutationBuilder extends LuxProjectFilterBuilder {
|
|
|
514
633
|
}
|
|
515
634
|
const params = this.filteredQueryParams();
|
|
516
635
|
const query = params.toString();
|
|
517
|
-
return
|
|
636
|
+
// Update/delete return the affected rows ({result: [rows]}); unwrap them.
|
|
637
|
+
const res = await this.client.request(this.method, `/tables/${encodeURIComponent(this.tableName)}${query ? `?${query}` : ''}`, this.body);
|
|
638
|
+
if (res.error)
|
|
639
|
+
return res;
|
|
640
|
+
return ok(unwrapResult(res.data));
|
|
518
641
|
}
|
|
519
642
|
}
|
|
520
643
|
function unwrapRows(payload) {
|
|
@@ -537,6 +660,16 @@ function normalizeWhere(where) {
|
|
|
537
660
|
function filtersToWhere(filters) {
|
|
538
661
|
return filters.map((filter) => {
|
|
539
662
|
const op = filterOperatorToWhere(filter.operator);
|
|
663
|
+
if (filter.operator === 'in' || filter.operator === 'notIn') {
|
|
664
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
665
|
+
return normalizeWhere(`${filter.column} ${op} ( ${values.map(formatWhereValue).join(' ')} )`);
|
|
666
|
+
}
|
|
667
|
+
if (filter.operator === 'isValid' ||
|
|
668
|
+
filter.operator === 'isNotValid' ||
|
|
669
|
+
filter.operator === 'isNull' ||
|
|
670
|
+
filter.operator === 'isNotNull') {
|
|
671
|
+
return normalizeWhere(`${filter.column} ${op}`);
|
|
672
|
+
}
|
|
540
673
|
return normalizeWhere(`${filter.column} ${op} ${formatWhereValue(filter.value)}`);
|
|
541
674
|
}).join(' AND ');
|
|
542
675
|
}
|
|
@@ -561,6 +694,20 @@ function filterOperatorToWhere(operator) {
|
|
|
561
694
|
return '<';
|
|
562
695
|
case 'lte':
|
|
563
696
|
return '<=';
|
|
697
|
+
case 'in':
|
|
698
|
+
return 'IN';
|
|
699
|
+
case 'notIn':
|
|
700
|
+
return 'NOT IN';
|
|
701
|
+
case 'isValid':
|
|
702
|
+
return 'IS VALID';
|
|
703
|
+
case 'isNotValid':
|
|
704
|
+
return 'IS NOT VALID';
|
|
705
|
+
case 'isNull':
|
|
706
|
+
return 'IS NULL';
|
|
707
|
+
case 'isNotNull':
|
|
708
|
+
return 'IS NOT NULL';
|
|
709
|
+
case 'contains':
|
|
710
|
+
return 'CONTAINS';
|
|
564
711
|
}
|
|
565
712
|
}
|
|
566
713
|
function formatWhereValue(value) {
|
package/dist/esm/table.js
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
import { err, ok, toLuxError } from './utils.js';
|
|
2
|
+
/** Serialize a field value: JSON objects/arrays round-trip as JSON text. */
|
|
3
|
+
function serializeFieldValue(v) {
|
|
4
|
+
if (v !== null && typeof v === 'object') {
|
|
5
|
+
return JSON.stringify(v);
|
|
6
|
+
}
|
|
7
|
+
return String(v);
|
|
8
|
+
}
|
|
9
|
+
/** Serialize one WHERE condition into RESP tokens. */
|
|
10
|
+
function serializeCondition(cond) {
|
|
11
|
+
if (cond.op === 'IN' || cond.op === 'NOT IN') {
|
|
12
|
+
const values = Array.isArray(cond.value) ? cond.value : [cond.value];
|
|
13
|
+
return [
|
|
14
|
+
cond.field,
|
|
15
|
+
...(cond.op === 'NOT IN' ? ['NOT', 'IN'] : ['IN']),
|
|
16
|
+
'(',
|
|
17
|
+
...values.map(String),
|
|
18
|
+
')',
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
if (cond.op === 'IS VALID')
|
|
22
|
+
return [cond.field, 'IS', 'VALID'];
|
|
23
|
+
if (cond.op === 'IS NOT VALID')
|
|
24
|
+
return [cond.field, 'IS', 'NOT', 'VALID'];
|
|
25
|
+
return [cond.field, cond.op, String(cond.value)];
|
|
26
|
+
}
|
|
27
|
+
/** Join serialized conditions with AND separators. */
|
|
28
|
+
function serializeConditions(conditions) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
31
|
+
out.push(...serializeCondition(conditions[i]));
|
|
32
|
+
if (i < conditions.length - 1)
|
|
33
|
+
out.push('AND');
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
2
37
|
export class TableSubscription {
|
|
3
38
|
constructor(client, table, selectArgsBuilder, initError = null) {
|
|
4
39
|
this.handlers = {
|
|
@@ -161,14 +196,7 @@ export class TableQueryBuilder {
|
|
|
161
196
|
args.push(...(this.joinClause.type === 'LEFT' ? ['LEFT', 'JOIN'] : ['JOIN']), this.joinClause.table, this.joinClause.alias, 'ON', this.joinClause.onLeft, '=', this.joinClause.onRight);
|
|
162
197
|
}
|
|
163
198
|
if (allConditions.length) {
|
|
164
|
-
args.push('WHERE');
|
|
165
|
-
for (let i = 0; i < allConditions.length; i++) {
|
|
166
|
-
const cond = allConditions[i];
|
|
167
|
-
args.push(cond.field, cond.op, String(cond.value));
|
|
168
|
-
if (i < allConditions.length - 1) {
|
|
169
|
-
args.push('AND');
|
|
170
|
-
}
|
|
171
|
-
}
|
|
199
|
+
args.push('WHERE', ...serializeConditions(allConditions));
|
|
172
200
|
}
|
|
173
201
|
if (this.groupFields.length) {
|
|
174
202
|
args.push('GROUP', 'BY', ...this.groupFields);
|
|
@@ -233,6 +261,29 @@ export class TableQueryBuilder {
|
|
|
233
261
|
lte(field, value) {
|
|
234
262
|
return this.where(field, '<=', value);
|
|
235
263
|
}
|
|
264
|
+
in(field, values) {
|
|
265
|
+
this.conditions.push({ field, op: 'IN', value: values });
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
notIn(field, values) {
|
|
269
|
+
this.conditions.push({ field, op: 'NOT IN', value: values });
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
/** Match rows where a JSON dot-path resolves to a present, non-null value. */
|
|
273
|
+
isValid(field) {
|
|
274
|
+
this.conditions.push({ field, op: 'IS VALID', value: '' });
|
|
275
|
+
return this;
|
|
276
|
+
}
|
|
277
|
+
/** Match rows where a JSON dot-path is absent or resolves to null. */
|
|
278
|
+
isNotValid(field) {
|
|
279
|
+
this.conditions.push({ field, op: 'IS NOT VALID', value: '' });
|
|
280
|
+
return this;
|
|
281
|
+
}
|
|
282
|
+
/** Match rows where an ARRAY column (or array-valued path) contains a value. */
|
|
283
|
+
contains(field, value) {
|
|
284
|
+
this.conditions.push({ field, op: 'CONTAINS', value });
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
236
287
|
orderBy(field, dir = 'asc') {
|
|
237
288
|
this.orderField = field;
|
|
238
289
|
this.orderDir = dir.toUpperCase();
|
|
@@ -308,7 +359,7 @@ export class TableQueryBuilder {
|
|
|
308
359
|
}
|
|
309
360
|
const args = [this.name];
|
|
310
361
|
for (const [k, v] of Object.entries(data)) {
|
|
311
|
-
args.push(k,
|
|
362
|
+
args.push(k, serializeFieldValue(v));
|
|
312
363
|
}
|
|
313
364
|
const result = await this.client.call('TINSERT', ...args);
|
|
314
365
|
return ok(parseInt(result, 10) || 0);
|
|
@@ -317,6 +368,26 @@ export class TableQueryBuilder {
|
|
|
317
368
|
return err('TINSERT_ERROR', `Failed to insert into '${this.name}'`, toLuxError(error));
|
|
318
369
|
}
|
|
319
370
|
}
|
|
371
|
+
/** Declare a typed index on a JSON dot-path, e.g. ('meta.reactions.count', 'int'). */
|
|
372
|
+
async createIndex(path, type) {
|
|
373
|
+
try {
|
|
374
|
+
await this.client.call('TINDEX', this.name, path, type.toUpperCase());
|
|
375
|
+
return ok(true);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
return err('TINDEX_ERROR', `Failed to index '${path}'`, toLuxError(error));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Drop a previously declared JSON path index. */
|
|
382
|
+
async dropIndex(path) {
|
|
383
|
+
try {
|
|
384
|
+
await this.client.call('TDROPINDEX', this.name, path);
|
|
385
|
+
return ok(true);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
return err('TDROPINDEX_ERROR', `Failed to drop index '${path}'`, toLuxError(error));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
320
391
|
async update(idOrData, data) {
|
|
321
392
|
try {
|
|
322
393
|
const hasExplicitId = data !== undefined;
|
|
@@ -326,20 +397,14 @@ export class TableQueryBuilder {
|
|
|
326
397
|
}
|
|
327
398
|
const args = [this.name, 'SET'];
|
|
328
399
|
for (const [k, v] of Object.entries(patch)) {
|
|
329
|
-
args.push(k,
|
|
400
|
+
args.push(k, serializeFieldValue(v));
|
|
330
401
|
}
|
|
331
402
|
args.push('WHERE');
|
|
332
403
|
if (hasExplicitId) {
|
|
333
404
|
args.push('id', '=', String(idOrData));
|
|
334
405
|
}
|
|
335
406
|
else {
|
|
336
|
-
|
|
337
|
-
const cond = this.conditions[i];
|
|
338
|
-
args.push(cond.field, cond.op, String(cond.value));
|
|
339
|
-
if (i < this.conditions.length - 1) {
|
|
340
|
-
args.push('AND');
|
|
341
|
-
}
|
|
342
|
-
}
|
|
407
|
+
args.push(...serializeConditions(this.conditions));
|
|
343
408
|
}
|
|
344
409
|
const result = await this.client.call('TUPDATE', ...args);
|
|
345
410
|
return ok(Number(result) || 0);
|
|
@@ -355,13 +420,7 @@ export class TableQueryBuilder {
|
|
|
355
420
|
return err('MISSING_WHERE', 'delete requires at least one filter');
|
|
356
421
|
}
|
|
357
422
|
const args = ['FROM', this.name, 'WHERE'];
|
|
358
|
-
|
|
359
|
-
const cond = this.conditions[i];
|
|
360
|
-
args.push(cond.field, cond.op, String(cond.value));
|
|
361
|
-
if (i < this.conditions.length - 1) {
|
|
362
|
-
args.push('AND');
|
|
363
|
-
}
|
|
364
|
-
}
|
|
423
|
+
args.push(...serializeConditions(this.conditions));
|
|
365
424
|
const result = await this.client.call('TDELETE', ...args);
|
|
366
425
|
return ok(Number(result) || 0);
|
|
367
426
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -6,11 +6,12 @@ import { TableQueryBuilder, type TableQueryBuilderOptions } from './table';
|
|
|
6
6
|
import type { KSubEvent, LuxTypedRow, TableRow, TSAddOptions, TSMRangeResult, TSRangeOptions, TSSample, VSearchResult } from './types';
|
|
7
7
|
export type { LuxAuthKey, LuxAuthGrantRow, LuxAuthIdentityRow, LuxAuthChangeEvent, LuxAuthOptions, LuxAuthKeyRow, LuxAuthProviderRow, LuxAuthSession, LuxAuthSessionRow, LuxAuthSigningKeyRow, LuxAuthStateChangeCallback, LuxAuthStorage, LuxAuthSubscription, LuxAuthTables, LuxAuthUserRow, LuxAuthUser, LuxUser, LuxOAuthProvider, LuxOAuthUrl, LuxSignInWithOAuthOptions, LuxCreateApiKeyOptions, LuxSignInOptions, LuxSignUpOptions, } from './auth';
|
|
8
8
|
export { createProjectClient, LuxProjectClient, };
|
|
9
|
+
export { LuxProjectLiveSubscription } from './project';
|
|
9
10
|
export { createBrowserClient } from './browser';
|
|
10
11
|
export type { LuxBrowserClientOptions } from './browser';
|
|
11
12
|
export { createServerClient } from './ssr';
|
|
12
13
|
export type { LuxCookieMethods, LuxCookieOptions, LuxServerClientOptions } from './ssr';
|
|
13
|
-
export type { LuxProjectLiveEvent, LuxProjectLiveEventType, LuxProjectOptions, LuxTableColumn, LuxVectorSearchOptions, } from './project';
|
|
14
|
+
export type { LuxLiveResult, LuxProjectLiveEvent, LuxProjectLiveEventType, LuxProjectOptions, LuxTableColumn, LuxVectorSearchOptions, } from './project';
|
|
14
15
|
export type { KSubEvent, LuxAggregateRow, LuxAggregateValue, LuxError, LuxInferRow, LuxNearRow, LuxResult, LuxSimilarity, LuxTypedRow, TableChangeEvent, TableChangeType, TableErrorEvent, TableRow, TableSchema, TSAddOptions, TSMRangeResult, TSRangeOptions, TSSample, VSearchResult, } from './types';
|
|
15
16
|
export { TableQueryBuilder, TableSubscription } from './table';
|
|
16
17
|
export type { TableQueryBuilderOptions } from './table';
|
package/dist/types/project.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LuxAuthClient, type LuxAuthOptions } from './auth';
|
|
2
|
-
import type { LuxResult, LuxTypedRow } from './types';
|
|
2
|
+
import type { LuxError, LuxResult, LuxTypedRow } from './types';
|
|
3
3
|
export interface LuxProjectOptions {
|
|
4
4
|
url: string;
|
|
5
5
|
key: string;
|
|
@@ -23,13 +23,13 @@ export interface LuxVectorSearchOptions {
|
|
|
23
23
|
filter_value?: string;
|
|
24
24
|
}
|
|
25
25
|
type QueryValue = string | number | boolean | number[] | null;
|
|
26
|
-
type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'is';
|
|
26
|
+
type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'is' | 'in' | 'notIn' | 'isValid' | 'isNotValid' | 'isNull' | 'isNotNull' | 'contains';
|
|
27
27
|
type ProjectRowInput<T extends object> = Partial<T> & Record<string, QueryValue>;
|
|
28
28
|
type ProjectSelectSingle<TResult> = TResult extends readonly (infer Row)[] ? Row : TResult;
|
|
29
29
|
interface QueryFilter {
|
|
30
30
|
column: string;
|
|
31
31
|
operator: FilterOperator;
|
|
32
|
-
value: QueryValue;
|
|
32
|
+
value: QueryValue | QueryValue[];
|
|
33
33
|
}
|
|
34
34
|
interface QueryOrder {
|
|
35
35
|
column: string;
|
|
@@ -69,6 +69,15 @@ export interface LuxProjectLiveEvent<T extends object = Record<string, unknown>>
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
type LiveEventHandler<T extends object> = (event: LuxProjectLiveEvent<T>) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Result of opening a live subscription, in the same `{ data, error }` spirit as
|
|
74
|
+
* the rest of the SDK: `live` is the established subscription (or `null` if the
|
|
75
|
+
* server rejected it), `error` carries the rejection (e.g. a grant `FORBIDDEN`).
|
|
76
|
+
*/
|
|
77
|
+
export interface LuxLiveResult<T extends object> {
|
|
78
|
+
live: LuxProjectLiveSubscription<T> | null;
|
|
79
|
+
error: LuxError | null;
|
|
80
|
+
}
|
|
72
81
|
export declare class LuxProjectClient {
|
|
73
82
|
readonly url: string;
|
|
74
83
|
readonly key: string;
|
|
@@ -119,9 +128,15 @@ export declare class LuxProjectTable<T extends object> {
|
|
|
119
128
|
threshold?: number;
|
|
120
129
|
}): LuxProjectSelectBuilder<T, T[]>;
|
|
121
130
|
is(column: string, value: QueryValue): LuxProjectSelectBuilder<T, T[]>;
|
|
122
|
-
live():
|
|
131
|
+
live(): Promise<LuxLiveResult<T>>;
|
|
123
132
|
insert(row: ProjectRowInput<T>): LuxProjectInsertBuilder<unknown>;
|
|
124
133
|
insert(rows: Array<ProjectRowInput<T>>): LuxProjectInsertBuilder<unknown[]>;
|
|
134
|
+
upsert(row: ProjectRowInput<T>, options?: {
|
|
135
|
+
onConflict?: string;
|
|
136
|
+
}): LuxProjectInsertBuilder<unknown>;
|
|
137
|
+
upsert(rows: Array<ProjectRowInput<T>>, options?: {
|
|
138
|
+
onConflict?: string;
|
|
139
|
+
}): LuxProjectInsertBuilder<unknown[]>;
|
|
125
140
|
update(patch: ProjectRowInput<T>): LuxProjectMutationBuilder<unknown>;
|
|
126
141
|
delete(): LuxProjectMutationBuilder<unknown>;
|
|
127
142
|
count(): Promise<LuxResult<number>>;
|
|
@@ -151,11 +166,18 @@ declare abstract class LuxProjectFilterBuilder<TResult, TSelf> extends LuxProjec
|
|
|
151
166
|
lt(column: string, value: QueryValue): TSelf;
|
|
152
167
|
lte(column: string, value: QueryValue): TSelf;
|
|
153
168
|
is(column: string, value: QueryValue): TSelf;
|
|
169
|
+
isNull(column: string): TSelf;
|
|
170
|
+
isNotNull(column: string): TSelf;
|
|
171
|
+
in(column: string, values: QueryValue[]): TSelf;
|
|
172
|
+
notIn(column: string, values: QueryValue[]): TSelf;
|
|
173
|
+
isValid(column: string): TSelf;
|
|
174
|
+
isNotValid(column: string): TSelf;
|
|
175
|
+
contains(column: string, value: QueryValue): TSelf;
|
|
154
176
|
join(table: string, alias: string, onLeft: string, onRight: string): TSelf;
|
|
155
177
|
leftJoin(table: string, alias: string, onLeft: string, onRight: string): TSelf;
|
|
156
178
|
group(columns: string | string[]): TSelf;
|
|
157
179
|
having(column: string, operator: FilterOperator, value: QueryValue): TSelf;
|
|
158
|
-
protected addFilter(column: string, operator: FilterOperator, value: QueryValue): TSelf;
|
|
180
|
+
protected addFilter(column: string, operator: FilterOperator, value: QueryValue | QueryValue[]): TSelf;
|
|
159
181
|
protected filteredQueryParams(): URLSearchParams;
|
|
160
182
|
}
|
|
161
183
|
export declare class LuxProjectSelectBuilder<T extends object, TResult> extends LuxProjectFilterBuilder<TResult, LuxProjectSelectBuilder<T, TResult>> {
|
|
@@ -173,7 +195,7 @@ export declare class LuxProjectSelectBuilder<T extends object, TResult> extends
|
|
|
173
195
|
range(from: number, to: number): this;
|
|
174
196
|
single(): LuxProjectSelectBuilder<T, ProjectSelectSingle<TResult>>;
|
|
175
197
|
execute(): Promise<LuxResult<TResult>>;
|
|
176
|
-
live():
|
|
198
|
+
live(): Promise<LuxLiveResult<LuxTypedRow<TResult>>>;
|
|
177
199
|
}
|
|
178
200
|
export declare class LuxProjectLiveSubscription<T extends object> {
|
|
179
201
|
private client;
|
|
@@ -186,19 +208,36 @@ export declare class LuxProjectLiveSubscription<T extends object> {
|
|
|
186
208
|
private offsetCount?;
|
|
187
209
|
private handlers;
|
|
188
210
|
private unsubscribeFn;
|
|
211
|
+
private queue;
|
|
212
|
+
private waiters;
|
|
213
|
+
private closed;
|
|
189
214
|
constructor(client: LuxProjectClient, table: string, columns: string, filters: QueryFilter[], nearQuery?: QueryNear | undefined, orderBy?: QueryOrder | undefined, limitCount?: number | undefined, offsetCount?: number | undefined);
|
|
190
215
|
on(type: LuxProjectLiveEventType | 'change', handler: LiveEventHandler<T>): this;
|
|
191
216
|
unsubscribe(): Promise<void>;
|
|
192
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Open the subscription and wait for the server to confirm it. Resolves
|
|
219
|
+
* `null` once the initial snapshot arrives, or a `LuxError` if the
|
|
220
|
+
* subscription is rejected (e.g. a grant `FORBIDDEN`) or the socket fails.
|
|
221
|
+
* Subsequent errors after a successful start surface via `on('error')` and
|
|
222
|
+
* end the async iterator.
|
|
223
|
+
*/
|
|
224
|
+
start(): Promise<LuxError | null>;
|
|
225
|
+
[Symbol.asyncIterator](): AsyncIterator<LuxProjectLiveEvent<T>>;
|
|
226
|
+
private close;
|
|
193
227
|
private spec;
|
|
194
228
|
private handleEvent;
|
|
195
229
|
private emit;
|
|
230
|
+
private pushIterator;
|
|
196
231
|
}
|
|
197
232
|
export declare class LuxProjectInsertBuilder<TResult> extends LuxProjectThenable<TResult> {
|
|
198
233
|
private client;
|
|
199
234
|
private tableName;
|
|
200
235
|
private rowOrRows;
|
|
201
|
-
|
|
236
|
+
private upsertOptions?;
|
|
237
|
+
constructor(client: LuxProjectClient, tableName: string, rowOrRows: Record<string, QueryValue> | Array<Record<string, QueryValue>>, upsertOptions?: {
|
|
238
|
+
upsert: boolean;
|
|
239
|
+
onConflict?: string;
|
|
240
|
+
} | undefined);
|
|
202
241
|
execute(): Promise<LuxResult<TResult>>;
|
|
203
242
|
}
|
|
204
243
|
export declare class LuxProjectMutationBuilder<TResult> extends LuxProjectFilterBuilder<TResult, LuxProjectMutationBuilder<TResult>> {
|
package/dist/types/table.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { KSubEvent, LuxError, LuxResult, TableChangeEvent, TableErrorEvent, TableRow, TableSchema } from './types';
|
|
2
|
-
type TableWhereOp = '=' | '!=' | '>' | '<' | '>=' | '<=';
|
|
2
|
+
type TableWhereOp = '=' | '!=' | '>' | '<' | '>=' | '<=' | 'IN' | 'NOT IN' | 'IS VALID' | 'IS NOT VALID' | 'CONTAINS';
|
|
3
3
|
type TableWhereValue = string | number | boolean;
|
|
4
4
|
interface TableWhereCondition {
|
|
5
5
|
field: string;
|
|
6
6
|
op: TableWhereOp;
|
|
7
|
-
value: TableWhereValue;
|
|
7
|
+
value: TableWhereValue | TableWhereValue[];
|
|
8
8
|
}
|
|
9
9
|
interface TableClient {
|
|
10
10
|
call(command: string, ...args: Array<string | number>): Promise<unknown>;
|
|
@@ -60,6 +60,14 @@ export declare class TableQueryBuilder<T extends object = TableRow> {
|
|
|
60
60
|
gte(field: string, value: TableWhereValue): this;
|
|
61
61
|
lt(field: string, value: TableWhereValue): this;
|
|
62
62
|
lte(field: string, value: TableWhereValue): this;
|
|
63
|
+
in(field: string, values: TableWhereValue[]): this;
|
|
64
|
+
notIn(field: string, values: TableWhereValue[]): this;
|
|
65
|
+
/** Match rows where a JSON dot-path resolves to a present, non-null value. */
|
|
66
|
+
isValid(field: string): this;
|
|
67
|
+
/** Match rows where a JSON dot-path is absent or resolves to null. */
|
|
68
|
+
isNotValid(field: string): this;
|
|
69
|
+
/** Match rows where an ARRAY column (or array-valued path) contains a value. */
|
|
70
|
+
contains(field: string, value: TableWhereValue): this;
|
|
63
71
|
orderBy(field: string, dir?: 'asc' | 'desc'): this;
|
|
64
72
|
order(field: string, options?: {
|
|
65
73
|
ascending?: boolean;
|
|
@@ -82,6 +90,10 @@ export declare class TableQueryBuilder<T extends object = TableRow> {
|
|
|
82
90
|
run(): Promise<LuxResult<T[] | T>>;
|
|
83
91
|
then<TFulfilled = LuxResult<T[] | T>, TRejected = never>(onfulfilled?: ((value: LuxResult<T[] | T>) => TFulfilled | PromiseLike<TFulfilled>) | null, onrejected?: ((reason: unknown) => TRejected | PromiseLike<TRejected>) | null): Promise<TFulfilled | TRejected>;
|
|
84
92
|
insert(data: Record<string, unknown>): Promise<LuxResult<number>>;
|
|
93
|
+
/** Declare a typed index on a JSON dot-path, e.g. ('meta.reactions.count', 'int'). */
|
|
94
|
+
createIndex(path: string, type: 'int' | 'float' | 'bool' | 'timestamp' | 'str'): Promise<LuxResult<true>>;
|
|
95
|
+
/** Drop a previously declared JSON path index. */
|
|
96
|
+
dropIndex(path: string): Promise<LuxResult<true>>;
|
|
85
97
|
update(id: number | string, data: Record<string, unknown>): Promise<LuxResult<number>>;
|
|
86
98
|
update(data: Record<string, unknown>): Promise<LuxResult<number>>;
|
|
87
99
|
delete(...ids: Array<number | string>): Promise<LuxResult<number>>;
|
package/package.json
CHANGED