@awesomate/sdk 0.4.0 → 0.5.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
@@ -104,6 +104,21 @@ app.auth.onChange((u) => render(u));
104
104
  await app.auth.signOut();
105
105
  ```
106
106
 
107
+ ### Lists that keep themselves current
108
+
109
+ ```ts
110
+ const stop = app.live('job', { orderBy: ['created_at', 'desc'], limit: 50 }, (rows) => render(rows));
111
+ // later: stop();
112
+ ```
113
+
114
+ `live()` calls you with the whole list at first and again whenever a row in it is added, changed,
115
+ removed, or stops being one this person may read (a job moved to another customer leaves their
116
+ list, with its memos). Changes made anywhere reach the list: in this app, by your team in Claude
117
+ Code, by an n8n workflow. One connection serves every list on the client; it reconnects by itself
118
+ and takes a fresh copy when it does. Up to 200 rows a list and 20 lists a connection. Pass
119
+ `{ onRows, onError }` to hear about a list the server refused (a column that does not exist).
120
+ Outside a browser, give it a WebSocket: `createAppClient({ publishableKey, WebSocket })`.
121
+
107
122
  The rules are kept in your own database, which applies them to every read and write, so a mistake
108
123
  in the app cannot show anyone more than their role allows. A disabled person is refused on their
109
124
  next call. Sessions are kept in `localStorage`; in a Capacitor app pass `storage` (Preferences) and
package/dist/index.d.ts CHANGED
@@ -15,13 +15,14 @@
15
15
  * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
16
  * await app.auth.completeSignIn(); // on /signed-in
17
17
  * const { rows } = await app.query('job', {}); // their jobs only
18
+ * const stop = app.live('job', {}, (rows) => render(rows)); // and kept current as they change
18
19
  *
19
20
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
20
21
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
21
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
22
23
  * crm:write, Support Plus and above.
23
24
  */
24
- export declare const VERSION = "0.4.0";
25
+ export declare const VERSION = "0.5.0";
25
26
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
26
27
  export interface Kinds {
27
28
  }
@@ -126,7 +127,8 @@ export interface Page<R> {
126
127
  };
127
128
  asAt: string;
128
129
  }
129
- export type ErrorCode = 'unauthenticated' | 'forbidden' | 'not_found' | 'validation' | 'consent_blocked' | 'rate_limited' | 'conflict' | 'unavailable';
130
+ declare const ERROR_CODES: readonly ["unauthenticated", "forbidden", "not_found", "validation", "consent_blocked", "rate_limited", "conflict", "unavailable"];
131
+ export type ErrorCode = (typeof ERROR_CODES)[number];
130
132
  export declare class AwesomateError extends Error {
131
133
  readonly code: ErrorCode;
132
134
  readonly status: number;
@@ -371,6 +373,37 @@ export interface AppClientOptions {
371
373
  storage?: SessionStorageLike;
372
374
  /** Default: one per publishable key. */
373
375
  storageKey?: string;
376
+ /** For live(): the WebSocket class. Default: the browser's (and Node 22's) global WebSocket. */
377
+ WebSocket?: WebSocketLike;
378
+ }
379
+ /** The part of the WebSocket API live() uses: the browser's, Node 22's, or the ws package's. */
380
+ export interface WebSocketLike {
381
+ new (url: string): {
382
+ readyState: number;
383
+ send(data: string): void;
384
+ close(code?: number, reason?: string): void;
385
+ onopen: ((ev: unknown) => void) | null;
386
+ onmessage: ((ev: {
387
+ data: unknown;
388
+ }) => void) | null;
389
+ onclose: ((ev: {
390
+ code: number;
391
+ reason: string;
392
+ }) => void) | null;
393
+ onerror: ((ev: unknown) => void) | null;
394
+ };
395
+ }
396
+ export interface LiveChange<R> {
397
+ /** Rows that are new or changed since the last call. */
398
+ upserts: R[];
399
+ /** Ids that left the list. */
400
+ removes: string[];
401
+ }
402
+ export interface LiveHandlers<R> {
403
+ /** Called with the whole list, in the query's order, at first and after every change. */
404
+ onRows: (rows: R[], change: LiveChange<R> | null) => void;
405
+ /** A refusal for this list (a column that does not exist, too many lists); the list stops. */
406
+ onError?: (err: AwesomateError) => void;
374
407
  }
375
408
  /** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
376
409
  export declare function signInTokenFrom(url: string): string | null;
@@ -383,6 +416,7 @@ export declare class AwesomateAppClient {
383
416
  private session;
384
417
  private refreshing;
385
418
  private readonly listeners;
419
+ private socket;
386
420
  constructor(opts: AppClientOptions);
387
421
  readonly auth: {
388
422
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
@@ -426,6 +460,13 @@ export declare class AwesomateAppClient {
426
460
  /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
427
461
  write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
428
462
  archive<K extends KindName>(kind: K, id: string): Promise<void>;
463
+ /**
464
+ * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
465
+ * and again whenever a row in it is added, changed, removed, or stops being one this person may
466
+ * read. One connection serves every live list on this client; it reconnects by itself and takes
467
+ * a fresh snapshot when it does. Returns stop().
468
+ */
469
+ live<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options: Omit<QueryOptions<RowOf<K>, S>, 'after'>, handlers: LiveHandlers<Pick<RowOf<K>, S>> | ((rows: Array<Pick<RowOf<K>, S>>, change: LiveChange<Pick<RowOf<K>, S>> | null) => void)): () => void;
429
470
  }
430
471
  export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
431
472
  export declare function createClient(options: ClientOptions): AwesomateClient;
package/dist/index.js CHANGED
@@ -15,14 +15,16 @@
15
15
  * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
16
  * await app.auth.completeSignIn(); // on /signed-in
17
17
  * const { rows } = await app.query('job', {}); // their jobs only
18
+ * const stop = app.live('job', {}, (rows) => render(rows)); // and kept current as they change
18
19
  *
19
20
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
20
21
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
21
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
22
23
  * crm:write, Support Plus and above.
23
24
  */
24
- export const VERSION = '0.4.0';
25
+ export const VERSION = '0.5.0';
25
26
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
27
+ const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
26
28
  export class AwesomateError extends Error {
27
29
  code;
28
30
  status;
@@ -231,6 +233,7 @@ export class AwesomateAppClient {
231
233
  session;
232
234
  refreshing = null;
233
235
  listeners = new Set();
236
+ socket = null;
234
237
  constructor(opts) {
235
238
  this.opts = opts;
236
239
  if (!opts?.publishableKey?.startsWith('pk_'))
@@ -323,6 +326,7 @@ export class AwesomateAppClient {
323
326
  await this.storage.setItem(this.key, JSON.stringify(s));
324
327
  for (const l of this.listeners)
325
328
  l(s.user);
329
+ this.socket?.tokenChanged(s);
326
330
  return s;
327
331
  }
328
332
  async clear() {
@@ -332,6 +336,8 @@ export class AwesomateAppClient {
332
336
  if (had)
333
337
  for (const l of this.listeners)
334
338
  l(null);
339
+ this.socket?.signedOut();
340
+ this.socket = null;
335
341
  }
336
342
  /** A session whose access token has at least half a minute left, refreshing if not. */
337
343
  async current() {
@@ -423,6 +429,204 @@ export class AwesomateAppClient {
423
429
  async archive(kind, id) {
424
430
  await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
425
431
  }
432
+ /**
433
+ * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
434
+ * and again whenever a row in it is added, changed, removed, or stops being one this person may
435
+ * read. One connection serves every live list on this client; it reconnects by itself and takes
436
+ * a fresh snapshot when it does. Returns stop().
437
+ */
438
+ live(kind, options, handlers) {
439
+ const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
440
+ this.socket ??= new LiveSocket({
441
+ url: `${this.base.replace(/^http/, 'ws')}/api/sdk/v1/socket`,
442
+ WebSocket: this.opts.WebSocket ?? globalThis.WebSocket,
443
+ session: () => this.current(),
444
+ refresh: async () => { const s = await this.load(); return s ? this.refresh(s) : null; },
445
+ });
446
+ return this.socket.add({ kind, ...options }, h);
447
+ }
448
+ }
449
+ /** Timers must never keep a Node process (a test, a server-side render) alive; browsers have no unref. */
450
+ function unref(t) {
451
+ t.unref?.();
452
+ return t;
453
+ }
454
+ /** The one WebSocket behind live(). */
455
+ class LiveSocket {
456
+ o;
457
+ ws = null;
458
+ ready = false;
459
+ entries = new Map();
460
+ seq = 0;
461
+ attempt = 0;
462
+ pinger = null;
463
+ refresher = null;
464
+ retry = null;
465
+ stopped = false;
466
+ /** One connection attempt at a time: two lists added together share it. */
467
+ connecting = false;
468
+ constructor(o) {
469
+ this.o = o;
470
+ }
471
+ add(spec, handlers) {
472
+ const id = `l${++this.seq}`;
473
+ this.entries.set(id, { spec, handlers, rows: new Map(), order: [] });
474
+ if (this.ready)
475
+ this.send({ type: 'sub', id, spec });
476
+ else if (!this.ws && !this.connecting)
477
+ void this.connect();
478
+ return () => {
479
+ if (!this.entries.delete(id))
480
+ return;
481
+ if (this.ready)
482
+ this.send({ type: 'unsub', id });
483
+ if (!this.entries.size)
484
+ this.shut();
485
+ };
486
+ }
487
+ send(f) {
488
+ if (this.ws && this.ws.readyState === 1)
489
+ this.ws.send(JSON.stringify(f));
490
+ }
491
+ async connect() {
492
+ if (this.stopped || this.ws || this.connecting || !this.entries.size)
493
+ return;
494
+ if (!this.o.WebSocket) {
495
+ this.failAll(new AwesomateError('unavailable', 'No WebSocket here: pass createAppClient({ WebSocket }).', 0));
496
+ return;
497
+ }
498
+ this.connecting = true;
499
+ let session;
500
+ try {
501
+ session = await this.o.session();
502
+ }
503
+ finally {
504
+ this.connecting = false;
505
+ }
506
+ if (!session) {
507
+ this.failAll(new AwesomateError('unauthenticated', 'Sign in first.', 401));
508
+ return;
509
+ }
510
+ if (this.stopped || this.ws || !this.entries.size)
511
+ return;
512
+ this.scheduleRefresh(session);
513
+ const ws = new this.o.WebSocket(this.o.url);
514
+ this.ws = ws;
515
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'hello', token: session.access_token }));
516
+ ws.onmessage = (ev) => this.frame(String(ev.data));
517
+ ws.onclose = (ev) => void this.closed(ws, ev.code);
518
+ ws.onerror = () => undefined;
519
+ }
520
+ frame(raw) {
521
+ let f;
522
+ try {
523
+ f = JSON.parse(raw);
524
+ }
525
+ catch {
526
+ return;
527
+ }
528
+ if (f.type === 'ready') {
529
+ this.ready = true;
530
+ this.attempt = 0;
531
+ for (const [id, e] of this.entries)
532
+ this.send({ type: 'sub', id, spec: e.spec });
533
+ this.pinger = unref(setInterval(() => this.send({ type: 'ping' }), 25_000));
534
+ return;
535
+ }
536
+ const e = typeof f.id === 'string' ? this.entries.get(f.id) : undefined;
537
+ if (f.type === 'error') {
538
+ const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
539
+ const err = new AwesomateError(code, String(f.message ?? 'Refused.'), 0, typeof f.field === 'string' ? f.field : undefined, String(f.code));
540
+ if (e && typeof f.id === 'string') {
541
+ this.entries.delete(f.id);
542
+ e.handlers.onError?.(err);
543
+ }
544
+ return;
545
+ }
546
+ if (!e)
547
+ return;
548
+ const order = Array.isArray(f.order) ? f.order.map(String) : [];
549
+ if (f.type === 'snapshot') {
550
+ e.rows = new Map((f.rows ?? []).map((r) => [String(r.id), r]));
551
+ e.order = order;
552
+ e.handlers.onRows(order.map((id) => e.rows.get(id)).filter(Boolean), null);
553
+ }
554
+ else if (f.type === 'change') {
555
+ const upserts = f.upserts ?? [];
556
+ const removes = (f.removes ?? []).map(String);
557
+ for (const r of upserts)
558
+ e.rows.set(String(r.id), r);
559
+ for (const id of removes)
560
+ e.rows.delete(id);
561
+ e.order = order;
562
+ e.handlers.onRows(order.map((id) => e.rows.get(id)).filter(Boolean), { upserts, removes });
563
+ }
564
+ }
565
+ /** A refreshed session: the open socket carries on as the same person with the new token. */
566
+ tokenChanged(s) {
567
+ if (this.ready)
568
+ this.send({ type: 'token', token: s.access_token });
569
+ this.scheduleRefresh(s);
570
+ }
571
+ /** Keep the socket's token fresh even when the app makes no other calls. */
572
+ scheduleRefresh(s) {
573
+ if (this.refresher)
574
+ clearTimeout(this.refresher);
575
+ const ms = Math.max(5_000, s.expires_at - Date.now() - 60_000);
576
+ this.refresher = unref(setTimeout(() => { if (this.entries.size)
577
+ void this.o.refresh(); }, ms));
578
+ }
579
+ async closed(ws, code) {
580
+ if (this.ws !== ws)
581
+ return;
582
+ this.ws = null;
583
+ this.ready = false;
584
+ if (this.pinger)
585
+ clearInterval(this.pinger);
586
+ this.pinger = null;
587
+ if (this.stopped || !this.entries.size)
588
+ return;
589
+ if (code === 4401) {
590
+ // Refused as unauthenticated: one refresh; if that is refused too, the person is signed out.
591
+ const s = await this.o.refresh().catch(() => null);
592
+ if (!s)
593
+ return this.failAll(new AwesomateError('unauthenticated', 'Sign in again.', 401));
594
+ }
595
+ if (code === 4403)
596
+ return this.failAll(new AwesomateError('forbidden', 'Live updates are not available for this app here.', 403));
597
+ const wait = Math.min(30_000, 1000 * 2 ** this.attempt++) * (code === 4429 ? 4 : 1);
598
+ this.retry = unref(setTimeout(() => void this.connect(), wait));
599
+ }
600
+ failAll(err) {
601
+ const all = [...this.entries.values()];
602
+ this.entries.clear();
603
+ for (const e of all)
604
+ e.handlers.onError?.(err);
605
+ this.shut();
606
+ }
607
+ shut() {
608
+ if (this.retry)
609
+ clearTimeout(this.retry);
610
+ if (this.refresher)
611
+ clearTimeout(this.refresher);
612
+ if (this.pinger)
613
+ clearInterval(this.pinger);
614
+ this.retry = this.refresher = this.pinger = null;
615
+ const ws = this.ws;
616
+ this.ws = null;
617
+ this.ready = false;
618
+ if (ws && ws.readyState <= 1) {
619
+ try {
620
+ ws.send(JSON.stringify({ type: 'bye' }));
621
+ }
622
+ catch { /* closing */ }
623
+ ws.close(1000, 'bye');
624
+ }
625
+ }
626
+ signedOut() {
627
+ this.stopped = true;
628
+ this.failAll(new AwesomateError('unauthenticated', 'Signed out.', 401));
629
+ }
426
630
  }
427
631
  export function createAppClient(options) {
428
632
  return new AwesomateAppClient(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Your own Awesomate data from Node and the browser: query contacts and app data with generated types, and sign your app's own users in",
5
5
  "license": "MIT",
6
6
  "type": "module",