@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 +15 -0
- package/dist/index.d.ts +43 -2
- package/dist/index.js +205 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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.
|
|
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