@luxdb/sdk 1.4.2 → 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.
@@ -2,9 +2,13 @@ import { LuxAuthClient } from './auth.js';
2
2
  import { err, ok, toLuxError } from './utils.js';
3
3
  export class LuxProjectClient {
4
4
  constructor(options) {
5
+ this.liveSocket = null;
6
+ this.liveSubscriptions = new Map();
7
+ this.livePending = [];
5
8
  this.url = options.url.replace(/\/+$/, '');
6
9
  this.key = options.key;
7
10
  this.fetchImpl = resolveFetch(options.fetch);
11
+ this.WebSocketImpl = options.websocket;
8
12
  this.auth = new LuxAuthClient({
9
13
  ...options.auth,
10
14
  httpUrl: this.url,
@@ -79,6 +83,96 @@ export class LuxProjectClient {
79
83
  return err('LUX_PROJECT_REQUEST_ERROR', 'Lux request failed', toLuxError(error));
80
84
  }
81
85
  }
86
+ async _subscribeLive(spec, handler, error) {
87
+ const id = `sub_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
88
+ const record = { id, spec, handler, error };
89
+ this.liveSubscriptions.set(id, record);
90
+ await this.ensureLiveSocket();
91
+ this.sendLive({
92
+ type: 'live.subscribe',
93
+ id,
94
+ spec,
95
+ });
96
+ return () => {
97
+ this.liveSubscriptions.delete(id);
98
+ this.sendLive({ type: 'live.unsubscribe', id });
99
+ if (this.liveSubscriptions.size === 0) {
100
+ this.liveSocket?.close();
101
+ this.liveSocket = null;
102
+ }
103
+ };
104
+ }
105
+ async ensureLiveSocket() {
106
+ const WebSocketImpl = resolveWebSocket(this.WebSocketImpl);
107
+ this.WebSocketImpl = WebSocketImpl;
108
+ if (this.liveSocket &&
109
+ (this.liveSocket.readyState === WebSocketImpl.OPEN ||
110
+ this.liveSocket.readyState === WebSocketImpl.CONNECTING)) {
111
+ return;
112
+ }
113
+ const accessToken = await this.auth.getAccessToken();
114
+ const url = new URL(`${this.url}/live`);
115
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
116
+ url.searchParams.set('apikey', this.key);
117
+ if (accessToken)
118
+ url.searchParams.set('access_token', accessToken);
119
+ const socket = new WebSocketImpl(url.toString());
120
+ this.liveSocket = socket;
121
+ socket.onopen = () => {
122
+ for (const message of this.livePending.splice(0))
123
+ socket.send(message);
124
+ };
125
+ socket.onmessage = (event) => {
126
+ let message;
127
+ try {
128
+ message = JSON.parse(String(event.data));
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ const subscription = typeof message.id === 'string' ? this.liveSubscriptions.get(message.id) : null;
134
+ if (message.type === 'live.event' && subscription) {
135
+ subscription.handler(message.event);
136
+ return;
137
+ }
138
+ if (message.type === 'live.error') {
139
+ const target = subscription ? [subscription] : [...this.liveSubscriptions.values()];
140
+ for (const sub of target) {
141
+ sub.error(message.error || { code: 'LIVE_ERROR', message: 'Live subscription failed' });
142
+ }
143
+ }
144
+ };
145
+ socket.onerror = () => {
146
+ for (const subscription of this.liveSubscriptions.values()) {
147
+ subscription.error({ code: 'LIVE_SOCKET_ERROR', message: 'Live socket failed' });
148
+ }
149
+ };
150
+ socket.onclose = () => {
151
+ if (this.liveSocket === socket)
152
+ this.liveSocket = null;
153
+ if (this.liveSubscriptions.size > 0) {
154
+ for (const subscription of this.liveSubscriptions.values()) {
155
+ this.livePending.push(JSON.stringify({
156
+ type: 'live.subscribe',
157
+ id: subscription.id,
158
+ spec: subscription.spec,
159
+ }));
160
+ }
161
+ setTimeout(() => {
162
+ void this.ensureLiveSocket();
163
+ }, 1000);
164
+ }
165
+ };
166
+ }
167
+ sendLive(message) {
168
+ const payload = JSON.stringify(message);
169
+ const WebSocketImpl = this.WebSocketImpl;
170
+ if (WebSocketImpl && this.liveSocket?.readyState === WebSocketImpl.OPEN) {
171
+ this.liveSocket.send(payload);
172
+ return;
173
+ }
174
+ this.livePending.push(payload);
175
+ }
82
176
  }
83
177
  export class LuxProjectTable {
84
178
  constructor(client, name) {
@@ -88,6 +182,33 @@ export class LuxProjectTable {
88
182
  select(columns = '*') {
89
183
  return new LuxProjectSelectBuilder(this.client, this.name, columns);
90
184
  }
185
+ eq(column, value) {
186
+ return this.select().eq(column, value);
187
+ }
188
+ neq(column, value) {
189
+ return this.select().neq(column, value);
190
+ }
191
+ gt(column, value) {
192
+ return this.select().gt(column, value);
193
+ }
194
+ gte(column, value) {
195
+ return this.select().gte(column, value);
196
+ }
197
+ lt(column, value) {
198
+ return this.select().lt(column, value);
199
+ }
200
+ lte(column, value) {
201
+ return this.select().lte(column, value);
202
+ }
203
+ near(column, vector, options = {}) {
204
+ return this.select().near(column, vector, options);
205
+ }
206
+ is(column, value) {
207
+ return this.select().is(column, value);
208
+ }
209
+ live() {
210
+ return this.select().live();
211
+ }
91
212
  insert(rowOrRows) {
92
213
  return new LuxProjectInsertBuilder(this.client, this.name, rowOrRows);
93
214
  }
@@ -121,6 +242,9 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
121
242
  this.client = client;
122
243
  this.tableName = tableName;
123
244
  this.filters = [];
245
+ this.joins = [];
246
+ this.groupColumns = [];
247
+ this.havingFilters = [];
124
248
  }
125
249
  eq(column, value) {
126
250
  return this.addFilter(column, 'eq', value);
@@ -143,6 +267,39 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
143
267
  is(column, value) {
144
268
  return this.addFilter(column, 'is', value);
145
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
+ }
285
+ join(table, alias, onLeft, onRight) {
286
+ this.joins.push({ type: 'inner', table, alias, onLeft, onRight });
287
+ return this;
288
+ }
289
+ leftJoin(table, alias, onLeft, onRight) {
290
+ this.joins.push({ type: 'left', table, alias, onLeft, onRight });
291
+ return this;
292
+ }
293
+ group(columns) {
294
+ this.groupColumns = Array.isArray(columns)
295
+ ? columns
296
+ : columns.split(',').map((column) => column.trim()).filter(Boolean);
297
+ return this;
298
+ }
299
+ having(column, operator, value) {
300
+ this.havingFilters.push({ column, operator, value });
301
+ return this;
302
+ }
146
303
  addFilter(column, operator, value) {
147
304
  this.filters.push({ column, operator, value });
148
305
  return this;
@@ -151,6 +308,22 @@ class LuxProjectFilterBuilder extends LuxProjectThenable {
151
308
  const params = new URLSearchParams();
152
309
  if (this.filters.length)
153
310
  params.set('where', filtersToWhere(this.filters));
311
+ for (const join of this.joins) {
312
+ const kind = join.type === 'left' ? ':left' : '';
313
+ params.append('join', `${join.table}:${join.alias}${kind}:on(${join.onLeft}=${join.onRight})`);
314
+ }
315
+ if (this.groupColumns.length)
316
+ params.set('group', this.groupColumns.join(','));
317
+ if (this.havingFilters.length)
318
+ params.set('having', havingToWhere(this.havingFilters));
319
+ if (this.nearQuery) {
320
+ params.set('near_field', this.nearQuery.field);
321
+ params.set('near_vector', `[${this.nearQuery.vector.join(',')}]`);
322
+ params.set('near_k', String(this.nearQuery.k));
323
+ if (this.nearQuery.threshold != null) {
324
+ params.set('near_threshold', String(this.nearQuery.threshold));
325
+ }
326
+ }
154
327
  if (this.orderBy) {
155
328
  params.set('order', `${this.orderBy.column} ${this.orderBy.ascending ? 'ASC' : 'DESC'}`);
156
329
  }
@@ -171,6 +344,15 @@ export class LuxProjectSelectBuilder extends LuxProjectFilterBuilder {
171
344
  this.orderBy = { column, ascending: options.ascending ?? true };
172
345
  return this;
173
346
  }
347
+ near(column, vector, options = {}) {
348
+ this.nearQuery = {
349
+ field: column,
350
+ vector,
351
+ k: options.k ?? 10,
352
+ threshold: options.threshold,
353
+ };
354
+ return this;
355
+ }
174
356
  limit(count) {
175
357
  this.limitCount = count;
176
358
  return this;
@@ -203,6 +385,199 @@ export class LuxProjectSelectBuilder extends LuxProjectFilterBuilder {
203
385
  }
204
386
  return ok(rows[0]);
205
387
  }
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 };
396
+ }
397
+ }
398
+ export class LuxProjectLiveSubscription {
399
+ constructor(client, table, columns, filters, nearQuery, orderBy, limitCount, offsetCount) {
400
+ this.client = client;
401
+ this.table = table;
402
+ this.columns = columns;
403
+ this.filters = filters;
404
+ this.nearQuery = nearQuery;
405
+ this.orderBy = orderBy;
406
+ this.limitCount = limitCount;
407
+ this.offsetCount = offsetCount;
408
+ this.handlers = {
409
+ snapshot: [],
410
+ insert: [],
411
+ update: [],
412
+ delete: [],
413
+ error: [],
414
+ change: [],
415
+ };
416
+ this.unsubscribeFn = null;
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;
422
+ }
423
+ on(type, handler) {
424
+ this.handlers[type].push(handler);
425
+ return this;
426
+ }
427
+ async unsubscribe() {
428
+ this.unsubscribeFn?.();
429
+ this.unsubscribeFn = null;
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
+ */
439
+ async start() {
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
+ }
500
+ }
501
+ spec() {
502
+ const spec = {
503
+ kind: 'table',
504
+ table: this.table,
505
+ select: this.columns,
506
+ };
507
+ if (this.filters.length) {
508
+ spec.where = this.filters.map((filter) => ({
509
+ field: filter.column,
510
+ op: filterOperatorToWhere(filter.operator),
511
+ value: filter.value,
512
+ }));
513
+ }
514
+ if (this.nearQuery) {
515
+ spec.near = {
516
+ field: this.nearQuery.field,
517
+ vector: this.nearQuery.vector,
518
+ k: this.nearQuery.k,
519
+ threshold: this.nearQuery.threshold,
520
+ };
521
+ }
522
+ if (this.orderBy) {
523
+ spec.orderBy = {
524
+ field: this.orderBy.column,
525
+ dir: this.orderBy.ascending ? 'asc' : 'desc',
526
+ };
527
+ }
528
+ if (this.limitCount != null)
529
+ spec.limit = this.limitCount;
530
+ if (this.offsetCount != null)
531
+ spec.offset = this.offsetCount;
532
+ return spec;
533
+ }
534
+ handleEvent(raw) {
535
+ if (!raw || typeof raw !== 'object')
536
+ return;
537
+ const event = raw;
538
+ if (event.kind === 'snapshot') {
539
+ this.emit({
540
+ type: 'snapshot',
541
+ table: this.table,
542
+ new: null,
543
+ old: null,
544
+ rows: Array.isArray(event.rows) ? event.rows : [],
545
+ raw,
546
+ });
547
+ return;
548
+ }
549
+ if (event.kind === 'insert' || event.kind === 'update' || event.kind === 'delete') {
550
+ this.emit({
551
+ type: event.kind,
552
+ table: this.table,
553
+ pk: event.pk == null ? undefined : String(event.pk),
554
+ new: event.row ?? null,
555
+ old: event.previous ?? null,
556
+ changed: Array.isArray(event.changed) ? event.changed : undefined,
557
+ raw,
558
+ });
559
+ }
560
+ }
561
+ emit(event) {
562
+ for (const handler of this.handlers[event.type])
563
+ handler(event);
564
+ if (event.type !== 'snapshot' && event.type !== 'error') {
565
+ for (const handler of this.handlers.change)
566
+ handler(event);
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);
580
+ }
206
581
  }
207
582
  export class LuxProjectInsertBuilder extends LuxProjectThenable {
208
583
  constructor(client, tableName, rowOrRows) {
@@ -258,6 +633,19 @@ function normalizeWhere(where) {
258
633
  return where.trim().replace(/\s*(>=|<=|!=|=|>|<)\s*/g, ' $1 ');
259
634
  }
260
635
  function filtersToWhere(filters) {
636
+ return filters.map((filter) => {
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
+ }
645
+ return normalizeWhere(`${filter.column} ${op} ${formatWhereValue(filter.value)}`);
646
+ }).join(' AND ');
647
+ }
648
+ function havingToWhere(filters) {
261
649
  return filters.map((filter) => {
262
650
  const op = filterOperatorToWhere(filter.operator);
263
651
  return normalizeWhere(`${filter.column} ${op} ${formatWhereValue(filter.value)}`);
@@ -278,6 +666,16 @@ function filterOperatorToWhere(operator) {
278
666
  return '<';
279
667
  case 'lte':
280
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';
281
679
  }
282
680
  }
283
681
  function formatWhereValue(value) {
@@ -301,3 +699,10 @@ function resolveFetch(fetchImpl) {
301
699
  }
302
700
  return candidate;
303
701
  }
702
+ function resolveWebSocket(websocketImpl) {
703
+ const candidate = websocketImpl ?? globalThis.WebSocket;
704
+ if (!candidate) {
705
+ throw new Error('Lux project live subscriptions require a WebSocket implementation');
706
+ }
707
+ return candidate;
708
+ }