@luxdb/sdk 1.4.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,29 +96,67 @@ const { data: deleted, error: deleteError } = await lux
96
96
  .eq("id", inserted?.id);
97
97
  ```
98
98
 
99
+ ### Filters and JSON
100
+
101
+ Beyond `.eq/.neq/.gt/.gte/.lt/.lte`, the query builder supports `IN` lists, JSON
102
+ dot-paths, and arrays:
103
+
104
+ ```ts
105
+ await lux.table("users").select().in("id", [1, 2, 3]);
106
+ await lux.table("users").select().notIn("status", ["banned", "deleted"]);
107
+
108
+ // JSON columns round-trip as native objects (no manual JSON.stringify)
109
+ await lux.table("events").insert({ metadata: { plan: { tier: "pro" }, count: 0 } });
110
+
111
+ // Query JSON by dot-path, like a JS object. A path that does not resolve is a
112
+ // non-match, never an error.
113
+ await lux.table("events").select().eq("metadata.plan.tier", "pro");
114
+
115
+ // IS VALID is existence, not truthiness: 0 / false / "" all count as valid.
116
+ await lux.table("events").select().isValid("metadata.count");
117
+ await lux.table("events").select().isNotValid("metadata.deleted_at");
118
+
119
+ // Array membership, and a declared JSON-path index for range queries at scale.
120
+ await lux.table("events").select().contains("tags", "urgent");
121
+ await lux.table("events").createIndex("metadata.plan.tier", "str");
122
+ ```
123
+
99
124
  ## Live tables
100
125
 
101
126
  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
127
 
128
+ `.live()` resolves once the server confirms the subscription, returning the same
129
+ `{ data, error }` shape as the rest of the SDK (here named `{ live, error }`). If
130
+ the query isn't permitted by a read grant, `error` is populated and `live` is
131
+ `null`. The subscription is async-iterable: the buffered snapshot arrives first,
132
+ then live changes.
133
+
103
134
  ```ts
104
- const sub = lux
135
+ const { live, error } = await lux
105
136
  .table<{ id: string; channel_id: string; body: string }>("messages")
106
137
  .eq("channel_id", "general")
107
- .live()
108
- .on("snapshot", (event) => {
109
- console.log(event.rows);
110
- })
111
- .on("insert", (event) => {
112
- console.log(event.new);
113
- })
114
- .on("update", (event) => {
115
- console.log(event.old, event.new);
116
- })
117
- .on("delete", (event) => {
118
- console.log(event.old);
119
- });
120
-
121
- await sub.unsubscribe();
138
+ .live();
139
+
140
+ if (error) throw error;
141
+
142
+ for await (const event of live) {
143
+ if (event.type === "snapshot") console.log(event.rows);
144
+ else console.log(event.type, event.new ?? event.old);
145
+ }
146
+ ```
147
+
148
+ You can also attach callbacks instead of iterating:
149
+
150
+ ```ts
151
+ const { live, error } = await lux.table("messages").eq("channel_id", "general").live();
152
+ if (error) throw error;
153
+
154
+ live
155
+ .on("insert", (event) => console.log(event.new))
156
+ .on("update", (event) => console.log(event.old, event.new))
157
+ .on("delete", (event) => console.log(event.old));
158
+
159
+ await live.unsubscribe();
122
160
  ```
123
161
 
124
162
  ## OAuth
@@ -203,3 +241,4 @@ const value = await lux.get("hello");
203
241
  - Browser live subscriptions use the project publishable key plus the signed-in user's JWT.
204
242
  - Table `select()` accepts Lux's constrained projection grammar, not arbitrary SQL.
205
243
  - Direct `lux://` or `rediss://` database access uses the database password and is for trusted infrastructure.
244
+ - 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");
@@ -274,6 +274,21 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
274
274
  is(column, value) {
275
275
  return this.addFilter(column, 'is', value);
276
276
  }
277
+ in(column, values) {
278
+ return this.addFilter(column, 'in', values);
279
+ }
280
+ notIn(column, values) {
281
+ return this.addFilter(column, 'notIn', values);
282
+ }
283
+ isValid(column) {
284
+ return this.addFilter(column, 'isValid', '');
285
+ }
286
+ isNotValid(column) {
287
+ return this.addFilter(column, 'isNotValid', '');
288
+ }
289
+ contains(column, value) {
290
+ return this.addFilter(column, 'contains', value);
291
+ }
277
292
  join(table, alias, onLeft, onRight) {
278
293
  this.joins.push({ type: 'inner', table, alias, onLeft, onRight });
279
294
  return this;
@@ -377,8 +392,14 @@ class LuxProjectSelectBuilder extends LuxProjectFilterBuilder {
377
392
  }
378
393
  return (0, utils_1.ok)(rows[0]);
379
394
  }
380
- live() {
381
- return new LuxProjectLiveSubscription(this.client, this.tableName, this.columns, this.filters, this.nearQuery, this.orderBy, this.limitCount, this.offsetCount);
395
+ async live() {
396
+ const live = new LuxProjectLiveSubscription(this.client, this.tableName, this.columns, this.filters, this.nearQuery, this.orderBy, this.limitCount, this.offsetCount);
397
+ const error = await live.start();
398
+ if (error) {
399
+ await live.unsubscribe();
400
+ return { live: null, error };
401
+ }
402
+ return { live, error: null };
382
403
  }
383
404
  }
384
405
  exports.LuxProjectSelectBuilder = LuxProjectSelectBuilder;
@@ -401,7 +422,11 @@ class LuxProjectLiveSubscription {
401
422
  change: [],
402
423
  };
403
424
  this.unsubscribeFn = null;
404
- void this.start();
425
+ // Async-iterator plumbing: events buffer in `queue` until a `for await`
426
+ // consumer pulls them; pending `next()` calls park in `waiters`.
427
+ this.queue = [];
428
+ this.waiters = [];
429
+ this.closed = false;
405
430
  }
406
431
  on(type, handler) {
407
432
  this.handlers[type].push(handler);
@@ -410,15 +435,76 @@ class LuxProjectLiveSubscription {
410
435
  async unsubscribe() {
411
436
  this.unsubscribeFn?.();
412
437
  this.unsubscribeFn = null;
413
- }
438
+ this.close();
439
+ }
440
+ /**
441
+ * Open the subscription and wait for the server to confirm it. Resolves
442
+ * `null` once the initial snapshot arrives, or a `LuxError` if the
443
+ * subscription is rejected (e.g. a grant `FORBIDDEN`) or the socket fails.
444
+ * Subsequent errors after a successful start surface via `on('error')` and
445
+ * end the async iterator.
446
+ */
414
447
  async start() {
415
- this.unsubscribeFn = await this.client._subscribeLive(this.spec(), (event) => this.handleEvent(event), (error) => this.emit({
416
- type: 'error',
417
- table: this.table,
418
- new: null,
419
- old: null,
420
- error,
421
- }));
448
+ let settled = false;
449
+ let settle;
450
+ const ready = new Promise((resolve) => {
451
+ settle = (error) => {
452
+ if (settled)
453
+ return;
454
+ settled = true;
455
+ resolve(error);
456
+ };
457
+ });
458
+ // Safety net: a server that never answers shouldn't hang the caller.
459
+ const timeout = setTimeout(() => {
460
+ settle({ code: 'LIVE_TIMEOUT', message: 'Timed out establishing live subscription' });
461
+ }, 15000);
462
+ this.unsubscribeFn = await this.client._subscribeLive(this.spec(), (event) => {
463
+ const kind = event?.kind;
464
+ this.handleEvent(event);
465
+ if (kind === 'snapshot')
466
+ settle(null);
467
+ }, (error) => {
468
+ const luxError = {
469
+ code: error.code ?? 'LIVE_ERROR',
470
+ message: error.message ?? 'Live subscription failed',
471
+ };
472
+ if (settled) {
473
+ // Post-start failure: notify handlers and end the stream.
474
+ this.emit({ type: 'error', table: this.table, new: null, old: null, error });
475
+ this.close();
476
+ }
477
+ else {
478
+ settle(luxError);
479
+ }
480
+ });
481
+ const result = await ready;
482
+ clearTimeout(timeout);
483
+ return result;
484
+ }
485
+ [Symbol.asyncIterator]() {
486
+ return {
487
+ next: () => {
488
+ const buffered = this.queue.shift();
489
+ if (buffered)
490
+ return Promise.resolve({ value: buffered, done: false });
491
+ if (this.closed)
492
+ return Promise.resolve({ value: undefined, done: true });
493
+ return new Promise((resolve) => this.waiters.push(resolve));
494
+ },
495
+ return: () => {
496
+ void this.unsubscribe();
497
+ return Promise.resolve({ value: undefined, done: true });
498
+ },
499
+ };
500
+ }
501
+ close() {
502
+ if (this.closed)
503
+ return;
504
+ this.closed = true;
505
+ for (const waiter of this.waiters.splice(0)) {
506
+ waiter({ value: undefined, done: true });
507
+ }
422
508
  }
423
509
  spec() {
424
510
  const spec = {
@@ -487,6 +573,18 @@ class LuxProjectLiveSubscription {
487
573
  for (const handler of this.handlers.change)
488
574
  handler(event);
489
575
  }
576
+ // Feed `for await` consumers the data events (errors end the stream via close()).
577
+ if (event.type !== 'error')
578
+ this.pushIterator(event);
579
+ }
580
+ pushIterator(event) {
581
+ if (this.closed)
582
+ return;
583
+ const waiter = this.waiters.shift();
584
+ if (waiter)
585
+ waiter({ value: event, done: false });
586
+ else
587
+ this.queue.push(event);
490
588
  }
491
589
  }
492
590
  exports.LuxProjectLiveSubscription = LuxProjectLiveSubscription;
@@ -548,6 +646,13 @@ function normalizeWhere(where) {
548
646
  function filtersToWhere(filters) {
549
647
  return filters.map((filter) => {
550
648
  const op = filterOperatorToWhere(filter.operator);
649
+ if (filter.operator === 'in' || filter.operator === 'notIn') {
650
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
651
+ return normalizeWhere(`${filter.column} ${op} ( ${values.map(formatWhereValue).join(' ')} )`);
652
+ }
653
+ if (filter.operator === 'isValid' || filter.operator === 'isNotValid') {
654
+ return normalizeWhere(`${filter.column} ${op}`);
655
+ }
551
656
  return normalizeWhere(`${filter.column} ${op} ${formatWhereValue(filter.value)}`);
552
657
  }).join(' AND ');
553
658
  }
@@ -572,6 +677,16 @@ function filterOperatorToWhere(operator) {
572
677
  return '<';
573
678
  case 'lte':
574
679
  return '<=';
680
+ case 'in':
681
+ return 'IN';
682
+ case 'notIn':
683
+ return 'NOT IN';
684
+ case 'isValid':
685
+ return 'IS VALID';
686
+ case 'isNotValid':
687
+ return 'IS NOT VALID';
688
+ case 'contains':
689
+ return 'CONTAINS';
575
690
  }
576
691
  }
577
692
  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, String(v));
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, String(v));
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
- for (let i = 0; i < this.conditions.length; i++) {
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
- for (let i = 0; i < this.conditions.length; i++) {
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';
@@ -267,6 +267,21 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
267
267
  is(column, value) {
268
268
  return this.addFilter(column, 'is', value);
269
269
  }
270
+ in(column, values) {
271
+ return this.addFilter(column, 'in', values);
272
+ }
273
+ notIn(column, values) {
274
+ return this.addFilter(column, 'notIn', values);
275
+ }
276
+ isValid(column) {
277
+ return this.addFilter(column, 'isValid', '');
278
+ }
279
+ isNotValid(column) {
280
+ return this.addFilter(column, 'isNotValid', '');
281
+ }
282
+ contains(column, value) {
283
+ return this.addFilter(column, 'contains', value);
284
+ }
270
285
  join(table, alias, onLeft, onRight) {
271
286
  this.joins.push({ type: 'inner', table, alias, onLeft, onRight });
272
287
  return this;
@@ -370,8 +385,14 @@ export class LuxProjectSelectBuilder extends LuxProjectFilterBuilder {
370
385
  }
371
386
  return ok(rows[0]);
372
387
  }
373
- live() {
374
- return new LuxProjectLiveSubscription(this.client, this.tableName, this.columns, this.filters, this.nearQuery, this.orderBy, this.limitCount, this.offsetCount);
388
+ async live() {
389
+ const live = new LuxProjectLiveSubscription(this.client, this.tableName, this.columns, this.filters, this.nearQuery, this.orderBy, this.limitCount, this.offsetCount);
390
+ const error = await live.start();
391
+ if (error) {
392
+ await live.unsubscribe();
393
+ return { live: null, error };
394
+ }
395
+ return { live, error: null };
375
396
  }
376
397
  }
377
398
  export class LuxProjectLiveSubscription {
@@ -393,7 +414,11 @@ export class LuxProjectLiveSubscription {
393
414
  change: [],
394
415
  };
395
416
  this.unsubscribeFn = null;
396
- void this.start();
417
+ // Async-iterator plumbing: events buffer in `queue` until a `for await`
418
+ // consumer pulls them; pending `next()` calls park in `waiters`.
419
+ this.queue = [];
420
+ this.waiters = [];
421
+ this.closed = false;
397
422
  }
398
423
  on(type, handler) {
399
424
  this.handlers[type].push(handler);
@@ -402,15 +427,76 @@ export class LuxProjectLiveSubscription {
402
427
  async unsubscribe() {
403
428
  this.unsubscribeFn?.();
404
429
  this.unsubscribeFn = null;
405
- }
430
+ this.close();
431
+ }
432
+ /**
433
+ * Open the subscription and wait for the server to confirm it. Resolves
434
+ * `null` once the initial snapshot arrives, or a `LuxError` if the
435
+ * subscription is rejected (e.g. a grant `FORBIDDEN`) or the socket fails.
436
+ * Subsequent errors after a successful start surface via `on('error')` and
437
+ * end the async iterator.
438
+ */
406
439
  async start() {
407
- this.unsubscribeFn = await this.client._subscribeLive(this.spec(), (event) => this.handleEvent(event), (error) => this.emit({
408
- type: 'error',
409
- table: this.table,
410
- new: null,
411
- old: null,
412
- error,
413
- }));
440
+ let settled = false;
441
+ let settle;
442
+ const ready = new Promise((resolve) => {
443
+ settle = (error) => {
444
+ if (settled)
445
+ return;
446
+ settled = true;
447
+ resolve(error);
448
+ };
449
+ });
450
+ // Safety net: a server that never answers shouldn't hang the caller.
451
+ const timeout = setTimeout(() => {
452
+ settle({ code: 'LIVE_TIMEOUT', message: 'Timed out establishing live subscription' });
453
+ }, 15000);
454
+ this.unsubscribeFn = await this.client._subscribeLive(this.spec(), (event) => {
455
+ const kind = event?.kind;
456
+ this.handleEvent(event);
457
+ if (kind === 'snapshot')
458
+ settle(null);
459
+ }, (error) => {
460
+ const luxError = {
461
+ code: error.code ?? 'LIVE_ERROR',
462
+ message: error.message ?? 'Live subscription failed',
463
+ };
464
+ if (settled) {
465
+ // Post-start failure: notify handlers and end the stream.
466
+ this.emit({ type: 'error', table: this.table, new: null, old: null, error });
467
+ this.close();
468
+ }
469
+ else {
470
+ settle(luxError);
471
+ }
472
+ });
473
+ const result = await ready;
474
+ clearTimeout(timeout);
475
+ return result;
476
+ }
477
+ [Symbol.asyncIterator]() {
478
+ return {
479
+ next: () => {
480
+ const buffered = this.queue.shift();
481
+ if (buffered)
482
+ return Promise.resolve({ value: buffered, done: false });
483
+ if (this.closed)
484
+ return Promise.resolve({ value: undefined, done: true });
485
+ return new Promise((resolve) => this.waiters.push(resolve));
486
+ },
487
+ return: () => {
488
+ void this.unsubscribe();
489
+ return Promise.resolve({ value: undefined, done: true });
490
+ },
491
+ };
492
+ }
493
+ close() {
494
+ if (this.closed)
495
+ return;
496
+ this.closed = true;
497
+ for (const waiter of this.waiters.splice(0)) {
498
+ waiter({ value: undefined, done: true });
499
+ }
414
500
  }
415
501
  spec() {
416
502
  const spec = {
@@ -479,6 +565,18 @@ export class LuxProjectLiveSubscription {
479
565
  for (const handler of this.handlers.change)
480
566
  handler(event);
481
567
  }
568
+ // Feed `for await` consumers the data events (errors end the stream via close()).
569
+ if (event.type !== 'error')
570
+ this.pushIterator(event);
571
+ }
572
+ pushIterator(event) {
573
+ if (this.closed)
574
+ return;
575
+ const waiter = this.waiters.shift();
576
+ if (waiter)
577
+ waiter({ value: event, done: false });
578
+ else
579
+ this.queue.push(event);
482
580
  }
483
581
  }
484
582
  export class LuxProjectInsertBuilder extends LuxProjectThenable {
@@ -537,6 +635,13 @@ function normalizeWhere(where) {
537
635
  function filtersToWhere(filters) {
538
636
  return filters.map((filter) => {
539
637
  const op = filterOperatorToWhere(filter.operator);
638
+ if (filter.operator === 'in' || filter.operator === 'notIn') {
639
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
640
+ return normalizeWhere(`${filter.column} ${op} ( ${values.map(formatWhereValue).join(' ')} )`);
641
+ }
642
+ if (filter.operator === 'isValid' || filter.operator === 'isNotValid') {
643
+ return normalizeWhere(`${filter.column} ${op}`);
644
+ }
540
645
  return normalizeWhere(`${filter.column} ${op} ${formatWhereValue(filter.value)}`);
541
646
  }).join(' AND ');
542
647
  }
@@ -561,6 +666,16 @@ function filterOperatorToWhere(operator) {
561
666
  return '<';
562
667
  case 'lte':
563
668
  return '<=';
669
+ case 'in':
670
+ return 'IN';
671
+ case 'notIn':
672
+ return 'NOT IN';
673
+ case 'isValid':
674
+ return 'IS VALID';
675
+ case 'isNotValid':
676
+ return 'IS NOT VALID';
677
+ case 'contains':
678
+ return 'CONTAINS';
564
679
  }
565
680
  }
566
681
  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, String(v));
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, String(v));
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
- for (let i = 0; i < this.conditions.length; i++) {
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
- for (let i = 0; i < this.conditions.length; i++) {
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
  }
@@ -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';
@@ -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' | '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,7 +128,7 @@ 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(): LuxProjectLiveSubscription<T>;
131
+ live(): Promise<LuxLiveResult<T>>;
123
132
  insert(row: ProjectRowInput<T>): LuxProjectInsertBuilder<unknown>;
124
133
  insert(rows: Array<ProjectRowInput<T>>): LuxProjectInsertBuilder<unknown[]>;
125
134
  update(patch: ProjectRowInput<T>): LuxProjectMutationBuilder<unknown>;
@@ -151,11 +160,16 @@ declare abstract class LuxProjectFilterBuilder<TResult, TSelf> extends LuxProjec
151
160
  lt(column: string, value: QueryValue): TSelf;
152
161
  lte(column: string, value: QueryValue): TSelf;
153
162
  is(column: string, value: QueryValue): TSelf;
163
+ in(column: string, values: QueryValue[]): TSelf;
164
+ notIn(column: string, values: QueryValue[]): TSelf;
165
+ isValid(column: string): TSelf;
166
+ isNotValid(column: string): TSelf;
167
+ contains(column: string, value: QueryValue): TSelf;
154
168
  join(table: string, alias: string, onLeft: string, onRight: string): TSelf;
155
169
  leftJoin(table: string, alias: string, onLeft: string, onRight: string): TSelf;
156
170
  group(columns: string | string[]): TSelf;
157
171
  having(column: string, operator: FilterOperator, value: QueryValue): TSelf;
158
- protected addFilter(column: string, operator: FilterOperator, value: QueryValue): TSelf;
172
+ protected addFilter(column: string, operator: FilterOperator, value: QueryValue | QueryValue[]): TSelf;
159
173
  protected filteredQueryParams(): URLSearchParams;
160
174
  }
161
175
  export declare class LuxProjectSelectBuilder<T extends object, TResult> extends LuxProjectFilterBuilder<TResult, LuxProjectSelectBuilder<T, TResult>> {
@@ -173,7 +187,7 @@ export declare class LuxProjectSelectBuilder<T extends object, TResult> extends
173
187
  range(from: number, to: number): this;
174
188
  single(): LuxProjectSelectBuilder<T, ProjectSelectSingle<TResult>>;
175
189
  execute(): Promise<LuxResult<TResult>>;
176
- live(): LuxProjectLiveSubscription<LuxTypedRow<TResult>>;
190
+ live(): Promise<LuxLiveResult<LuxTypedRow<TResult>>>;
177
191
  }
178
192
  export declare class LuxProjectLiveSubscription<T extends object> {
179
193
  private client;
@@ -186,13 +200,26 @@ export declare class LuxProjectLiveSubscription<T extends object> {
186
200
  private offsetCount?;
187
201
  private handlers;
188
202
  private unsubscribeFn;
203
+ private queue;
204
+ private waiters;
205
+ private closed;
189
206
  constructor(client: LuxProjectClient, table: string, columns: string, filters: QueryFilter[], nearQuery?: QueryNear | undefined, orderBy?: QueryOrder | undefined, limitCount?: number | undefined, offsetCount?: number | undefined);
190
207
  on(type: LuxProjectLiveEventType | 'change', handler: LiveEventHandler<T>): this;
191
208
  unsubscribe(): Promise<void>;
192
- private start;
209
+ /**
210
+ * Open the subscription and wait for the server to confirm it. Resolves
211
+ * `null` once the initial snapshot arrives, or a `LuxError` if the
212
+ * subscription is rejected (e.g. a grant `FORBIDDEN`) or the socket fails.
213
+ * Subsequent errors after a successful start surface via `on('error')` and
214
+ * end the async iterator.
215
+ */
216
+ start(): Promise<LuxError | null>;
217
+ [Symbol.asyncIterator](): AsyncIterator<LuxProjectLiveEvent<T>>;
218
+ private close;
193
219
  private spec;
194
220
  private handleEvent;
195
221
  private emit;
222
+ private pushIterator;
196
223
  }
197
224
  export declare class LuxProjectInsertBuilder<TResult> extends LuxProjectThenable<TResult> {
198
225
  private client;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luxdb/sdk",
3
- "version": "1.4.3",
3
+ "version": "2.0.0",
4
4
  "description": "Official Lux TypeScript SDK for app data, auth, tables, vectors, realtime, and Redis-compatible direct access",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "module": "./dist/esm/index.js",