@awesomate/sdk 0.2.0 → 0.4.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 +32 -1
- package/dist/index.d.ts +205 -4
- package/dist/index.js +292 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -77,6 +77,37 @@ Every failure is an `AwesomateError` with a `code`: `unauthenticated`, `forbidde
|
|
|
77
77
|
## Keep the token on a server
|
|
78
78
|
|
|
79
79
|
The token is your account's hosting token. Use it in a server, a script or a scheduled job, never
|
|
80
|
-
in a browser.
|
|
80
|
+
in a browser. For a browser, sign your app's own users in (below).
|
|
81
|
+
|
|
82
|
+
## Your app's users sign in (Pro and above)
|
|
83
|
+
|
|
84
|
+
Set the app up first (Claude Code: `awesomate_crm_apps`): its name, the origins it runs on, and
|
|
85
|
+
whether anyone may sign up or only people you add. That gives you a publishable key, which is not a
|
|
86
|
+
secret. Then say who may read and change each kind (`awesomate_crm_kinds`, `set_access`), for
|
|
87
|
+
example members read jobs whose customer is them and the memos on those jobs.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createAppClient } from '@awesomate/sdk';
|
|
91
|
+
|
|
92
|
+
const app = createAppClient({ publishableKey: 'pk_...' });
|
|
93
|
+
|
|
94
|
+
// The sign-in page: an email link, no password. The answer is the same for any address.
|
|
95
|
+
await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
|
|
96
|
+
|
|
97
|
+
// /signed-in: exchanges the link for a session and clears it from the address bar.
|
|
98
|
+
const user = await app.auth.completeSignIn();
|
|
99
|
+
|
|
100
|
+
// Everywhere else: the same calls as the server client, as this person.
|
|
101
|
+
const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'] });
|
|
102
|
+
await app.write('forum_post', { body: 'Booked for Tuesday' }, { links: { job: rows[0].id } });
|
|
103
|
+
app.auth.onChange((u) => render(u));
|
|
104
|
+
await app.auth.signOut();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The rules are kept in your own database, which applies them to every read and write, so a mistake
|
|
108
|
+
in the app cannot show anyone more than their role allows. A disabled person is refused on their
|
|
109
|
+
next call. Sessions are kept in `localStorage`; in a Capacitor app pass `storage` (Preferences) and
|
|
110
|
+
call `completeSignIn(url)` from the deep link that opened the app. Your own server can verify
|
|
111
|
+
`await app.auth.accessToken()` against `https://hub.awesomate.ai/api/sdk/v1/jwks.json`.
|
|
81
112
|
|
|
82
113
|
MIT licence.
|
package/dist/index.d.ts
CHANGED
|
@@ -6,16 +6,44 @@
|
|
|
6
6
|
* const db = createClient({ token: process.env.AWESOMATE_TOKEN! });
|
|
7
7
|
* const { rows, next } = await db.query('contact', { where: { created_at: { gte: '$YEAR_BEGIN' } }, limit: 50 });
|
|
8
8
|
*
|
|
9
|
+
* const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
|
|
10
|
+
* await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
|
|
11
|
+
*
|
|
12
|
+
* In the browser, an app's own users sign in by email link and read only what the account's
|
|
13
|
+
* access rules let them (Pro and above):
|
|
14
|
+
* const app = createAppClient({ publishableKey: 'pk_...' });
|
|
15
|
+
* await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
|
|
16
|
+
* await app.auth.completeSignIn(); // on /signed-in
|
|
17
|
+
* const { rows } = await app.query('job', {}); // their jobs only
|
|
18
|
+
*
|
|
9
19
|
* The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
|
|
10
|
-
* browser. End-user sign-in for browser apps comes in a later version.
|
|
11
|
-
*
|
|
20
|
+
* browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
|
|
21
|
+
* queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
|
|
22
|
+
* crm:write, Support Plus and above.
|
|
12
23
|
*/
|
|
13
|
-
export declare const VERSION = "0.
|
|
24
|
+
export declare const VERSION = "0.4.0";
|
|
14
25
|
/** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
|
|
15
26
|
export interface Kinds {
|
|
16
27
|
}
|
|
17
28
|
export type KindName = [keyof Kinds] extends [never] ? string : Extract<keyof Kinds, string>;
|
|
18
29
|
export type RowOf<K> = K extends keyof Kinds ? Kinds[K] : Record<string, unknown>;
|
|
30
|
+
/** Augmented by the generated awesomate.d.ts: each saved query's params and row. */
|
|
31
|
+
export interface Queries {
|
|
32
|
+
}
|
|
33
|
+
/** Augmented by the generated awesomate.d.ts: each write recipe's args. */
|
|
34
|
+
export interface Recipes {
|
|
35
|
+
}
|
|
36
|
+
export type QueryName = [keyof Queries] extends [never] ? string : Extract<keyof Queries, string>;
|
|
37
|
+
export type ParamsOf<Q> = Q extends keyof Queries ? (Queries[Q] extends {
|
|
38
|
+
params: infer P;
|
|
39
|
+
} ? P : never) : Record<string, unknown>;
|
|
40
|
+
export type QueryRowOf<Q> = Q extends keyof Queries ? (Queries[Q] extends {
|
|
41
|
+
row: infer R;
|
|
42
|
+
} ? R : never) : Record<string, unknown>;
|
|
43
|
+
export type RecipeName = [keyof Recipes] extends [never] ? string : Extract<keyof Recipes, string>;
|
|
44
|
+
export type ArgsOf<R> = R extends keyof Recipes ? (Recipes[R] extends {
|
|
45
|
+
args: infer A;
|
|
46
|
+
} ? A : never) : Record<string, unknown>;
|
|
19
47
|
type TextOps<V extends string> = {
|
|
20
48
|
eq?: V | null;
|
|
21
49
|
neq?: V | null;
|
|
@@ -123,7 +151,7 @@ export declare class AwesomateClient {
|
|
|
123
151
|
private readonly base;
|
|
124
152
|
private readonly doFetch;
|
|
125
153
|
constructor(opts: ClientOptions);
|
|
126
|
-
private
|
|
154
|
+
private request;
|
|
127
155
|
/** The kinds this token can read, their columns, operators and examples. */
|
|
128
156
|
schema(): Promise<{
|
|
129
157
|
kinds: Array<{
|
|
@@ -160,6 +188,34 @@ export declare class AwesomateClient {
|
|
|
160
188
|
write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
|
|
161
189
|
/** Archive a record: it leaves every read and is kept for a restore. */
|
|
162
190
|
archive<K extends KindName>(kind: K, id: string): Promise<void>;
|
|
191
|
+
/** The saved queries on this account. */
|
|
192
|
+
queries(): Promise<SavedQueryDescription[]>;
|
|
193
|
+
/**
|
|
194
|
+
* Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
|
|
195
|
+
* kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
|
|
196
|
+
* stored, so a column it cannot read is refused now rather than when it runs.
|
|
197
|
+
*/
|
|
198
|
+
saveQuery(spec: SavedQuerySpec): Promise<SavedQueryDescription>;
|
|
199
|
+
archiveQuery(key: string): Promise<void>;
|
|
200
|
+
/** Run a saved query with its params. An optional param left out drops its condition. */
|
|
201
|
+
run<Q extends QueryName>(query: Q, params?: ParamsOf<Q>, options?: {
|
|
202
|
+
limit?: number;
|
|
203
|
+
after?: string;
|
|
204
|
+
tz?: string;
|
|
205
|
+
}): Promise<Page<QueryRowOf<Q>>>;
|
|
206
|
+
/** The write recipes on this account. */
|
|
207
|
+
recipes(): Promise<RecipeDescription[]>;
|
|
208
|
+
/**
|
|
209
|
+
* Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
|
|
210
|
+
* {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
|
|
211
|
+
*/
|
|
212
|
+
saveRecipe(spec: RecipeSpec): Promise<RecipeDescription>;
|
|
213
|
+
archiveRecipe(key: string): Promise<void>;
|
|
214
|
+
/**
|
|
215
|
+
* Run a write recipe. Every step commits or none does; a refusal names the step and the field.
|
|
216
|
+
* Never retried: a write that may have landed is not safe to send twice.
|
|
217
|
+
*/
|
|
218
|
+
call<R extends RecipeName>(recipe: R, args?: ArgsOf<R>): Promise<RecipeResult>;
|
|
163
219
|
/** Grouped numbers (counts, sums) in the Business Data API's shape. */
|
|
164
220
|
aggregate(request: {
|
|
165
221
|
measures: Array<{
|
|
@@ -227,5 +283,150 @@ export interface WriteOptions {
|
|
|
227
283
|
/** Set a link by its key ('<id>'), or end it (null). */
|
|
228
284
|
links?: Record<string, string | null>;
|
|
229
285
|
}
|
|
286
|
+
export type ParamType = 'text' | 'number' | 'date' | 'datetime' | 'boolean' | 'uuid' | 'text_list' | 'number_list';
|
|
287
|
+
export interface ParamSpec {
|
|
288
|
+
name: string;
|
|
289
|
+
/** Default text. */
|
|
290
|
+
type?: ParamType;
|
|
291
|
+
required?: boolean;
|
|
292
|
+
default?: unknown;
|
|
293
|
+
label?: string;
|
|
294
|
+
}
|
|
295
|
+
/** Where a caller's value goes in a saved query or a recipe. */
|
|
296
|
+
export type Param = {
|
|
297
|
+
$param: string;
|
|
298
|
+
};
|
|
299
|
+
export interface SavedQuerySpec {
|
|
300
|
+
key: string;
|
|
301
|
+
label: string;
|
|
302
|
+
description?: string;
|
|
303
|
+
/** 'contact' or an app kind. */
|
|
304
|
+
kind: string;
|
|
305
|
+
spec: {
|
|
306
|
+
where?: Record<string, unknown>;
|
|
307
|
+
orderBy?: Array<[string, 'asc' | 'desc']>;
|
|
308
|
+
select?: string[];
|
|
309
|
+
limit?: number;
|
|
310
|
+
};
|
|
311
|
+
params?: ParamSpec[];
|
|
312
|
+
}
|
|
313
|
+
export interface SavedQueryDescription extends Required<Omit<SavedQuerySpec, 'description' | 'params'>> {
|
|
314
|
+
description: string | null;
|
|
315
|
+
params: ParamSpec[];
|
|
316
|
+
updated_at: string;
|
|
317
|
+
}
|
|
318
|
+
export type RecipeStep = {
|
|
319
|
+
op: 'write_record';
|
|
320
|
+
kind: string;
|
|
321
|
+
id?: Param | {
|
|
322
|
+
$step: string;
|
|
323
|
+
};
|
|
324
|
+
as?: string;
|
|
325
|
+
data?: Record<string, unknown>;
|
|
326
|
+
links?: Record<string, unknown>;
|
|
327
|
+
} | {
|
|
328
|
+
op: 'archive_record';
|
|
329
|
+
kind: string;
|
|
330
|
+
id: Param | {
|
|
331
|
+
$step: string;
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
export interface RecipeSpec {
|
|
335
|
+
key: string;
|
|
336
|
+
label: string;
|
|
337
|
+
description?: string;
|
|
338
|
+
params?: ParamSpec[];
|
|
339
|
+
steps: RecipeStep[];
|
|
340
|
+
}
|
|
341
|
+
export interface RecipeDescription extends Required<Omit<RecipeSpec, 'description' | 'params'>> {
|
|
342
|
+
description: string | null;
|
|
343
|
+
params: ParamSpec[];
|
|
344
|
+
updated_at: string;
|
|
345
|
+
}
|
|
346
|
+
export interface RecipeResult {
|
|
347
|
+
/** The id each step named with `as` wrote. */
|
|
348
|
+
ids: Record<string, string>;
|
|
349
|
+
/** Every record the recipe wrote or archived, in step order. */
|
|
350
|
+
records: string[];
|
|
351
|
+
}
|
|
352
|
+
/** Where a session is kept: localStorage, Capacitor Preferences, or anything with these three. */
|
|
353
|
+
export interface SessionStorageLike {
|
|
354
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
355
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
356
|
+
removeItem(key: string): void | Promise<void>;
|
|
357
|
+
}
|
|
358
|
+
export interface AppUser {
|
|
359
|
+
id: string;
|
|
360
|
+
email: string;
|
|
361
|
+
/** owner, staff, member or one of the account's own; the database re-reads it on every call */
|
|
362
|
+
role: string;
|
|
363
|
+
contact_id: string | null;
|
|
364
|
+
}
|
|
365
|
+
export interface AppClientOptions {
|
|
366
|
+
/** The app's publishable key (pk_...). Not a secret: it belongs in browser code. */
|
|
367
|
+
publishableKey: string;
|
|
368
|
+
baseUrl?: string;
|
|
369
|
+
fetch?: typeof fetch;
|
|
370
|
+
/** Default: localStorage in a browser, memory elsewhere. In Capacitor, pass Preferences. */
|
|
371
|
+
storage?: SessionStorageLike;
|
|
372
|
+
/** Default: one per publishable key. */
|
|
373
|
+
storageKey?: string;
|
|
374
|
+
}
|
|
375
|
+
/** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
|
|
376
|
+
export declare function signInTokenFrom(url: string): string | null;
|
|
377
|
+
export declare class AwesomateAppClient {
|
|
378
|
+
private readonly opts;
|
|
379
|
+
private readonly base;
|
|
380
|
+
private readonly doFetch;
|
|
381
|
+
private readonly storage;
|
|
382
|
+
private readonly key;
|
|
383
|
+
private session;
|
|
384
|
+
private refreshing;
|
|
385
|
+
private readonly listeners;
|
|
386
|
+
constructor(opts: AppClientOptions);
|
|
387
|
+
readonly auth: {
|
|
388
|
+
/** Email a sign-in link. The answer is the same whether or not the address can sign in. */
|
|
389
|
+
signInWithLink: (email: string, options: {
|
|
390
|
+
redirectTo: string;
|
|
391
|
+
}) => Promise<{
|
|
392
|
+
sent: boolean;
|
|
393
|
+
message: string;
|
|
394
|
+
}>;
|
|
395
|
+
/**
|
|
396
|
+
* Finish signing in from the link: the current page's address by default, or a URL a deep link
|
|
397
|
+
* opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
|
|
398
|
+
* token is removed from the browser's address bar.
|
|
399
|
+
*/
|
|
400
|
+
completeSignIn: (url?: string) => Promise<AppUser | null>;
|
|
401
|
+
/** The signed-in user, or null. */
|
|
402
|
+
user: () => Promise<AppUser | null>;
|
|
403
|
+
/** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
|
|
404
|
+
onChange: (listener: (user: AppUser | null) => void) => (() => void);
|
|
405
|
+
signOut: () => Promise<void>;
|
|
406
|
+
/** A current access token, for the app's own server to verify against the JWKS. */
|
|
407
|
+
accessToken: () => Promise<string | null>;
|
|
408
|
+
};
|
|
409
|
+
private request;
|
|
410
|
+
private read;
|
|
411
|
+
private load;
|
|
412
|
+
private save;
|
|
413
|
+
private clear;
|
|
414
|
+
/** A session whose access token has at least half a minute left, refreshing if not. */
|
|
415
|
+
private current;
|
|
416
|
+
/** One refresh at a time per client; a refresh another tab already did is picked up from storage. */
|
|
417
|
+
private refresh;
|
|
418
|
+
/** A data call as the signed-in user: refused once (a disable, a key change), it refreshes and tries again; refused twice, it signs out. */
|
|
419
|
+
private data;
|
|
420
|
+
query<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options?: QueryOptions<RowOf<K>, S>): Promise<Omit<Page<Pick<RowOf<K>, S>>, 'hidden'>>;
|
|
421
|
+
queryAll<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options?: Omit<QueryOptions<RowOf<K>, S>, 'after'> & {
|
|
422
|
+
maxRows?: number;
|
|
423
|
+
}): AsyncGenerator<Pick<RowOf<K>, S>>;
|
|
424
|
+
/** One record by id, or null when there is none this user may read. */
|
|
425
|
+
get<K extends KindName>(kind: K, id: string): Promise<RowOf<K> | null>;
|
|
426
|
+
/** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
|
|
427
|
+
write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
|
|
428
|
+
archive<K extends KindName>(kind: K, id: string): Promise<void>;
|
|
429
|
+
}
|
|
430
|
+
export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
|
|
230
431
|
export declare function createClient(options: ClientOptions): AwesomateClient;
|
|
231
432
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -6,11 +6,22 @@
|
|
|
6
6
|
* const db = createClient({ token: process.env.AWESOMATE_TOKEN! });
|
|
7
7
|
* const { rows, next } = await db.query('contact', { where: { created_at: { gte: '$YEAR_BEGIN' } }, limit: 50 });
|
|
8
8
|
*
|
|
9
|
+
* const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
|
|
10
|
+
* await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
|
|
11
|
+
*
|
|
12
|
+
* In the browser, an app's own users sign in by email link and read only what the account's
|
|
13
|
+
* access rules let them (Pro and above):
|
|
14
|
+
* const app = createAppClient({ publishableKey: 'pk_...' });
|
|
15
|
+
* await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
|
|
16
|
+
* await app.auth.completeSignIn(); // on /signed-in
|
|
17
|
+
* const { rows } = await app.query('job', {}); // their jobs only
|
|
18
|
+
*
|
|
9
19
|
* The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
|
|
10
|
-
* browser. End-user sign-in for browser apps comes in a later version.
|
|
11
|
-
*
|
|
20
|
+
* browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
|
|
21
|
+
* queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
|
|
22
|
+
* crm:write, Support Plus and above.
|
|
12
23
|
*/
|
|
13
|
-
export const VERSION = '0.
|
|
24
|
+
export const VERSION = '0.4.0';
|
|
14
25
|
const DEFAULT_BASE = 'https://hub.awesomate.ai';
|
|
15
26
|
export class AwesomateError extends Error {
|
|
16
27
|
code;
|
|
@@ -58,7 +69,7 @@ export class AwesomateClient {
|
|
|
58
69
|
if (!this.doFetch)
|
|
59
70
|
throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
|
|
60
71
|
}
|
|
61
|
-
async
|
|
72
|
+
async request(method, path, body, retry = true) {
|
|
62
73
|
const res = await this.doFetch(`${this.base}${path}`, {
|
|
63
74
|
method,
|
|
64
75
|
headers: {
|
|
@@ -81,20 +92,20 @@ export class AwesomateClient {
|
|
|
81
92
|
// The field list changed while the read ran (the view is rebuilt on every field edit): a read
|
|
82
93
|
// is safe to run again, once.
|
|
83
94
|
if (code === 'conflict' && retry)
|
|
84
|
-
return this.
|
|
95
|
+
return this.request(method, path, body, false);
|
|
85
96
|
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
86
97
|
throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
|
|
87
98
|
}
|
|
88
99
|
/** The kinds this token can read, their columns, operators and examples. */
|
|
89
100
|
schema() {
|
|
90
|
-
return this.
|
|
101
|
+
return this.request('GET', '/api/my-crm/v1/rows/schema');
|
|
91
102
|
}
|
|
92
103
|
/** The readable kinds as a TypeScript file (what the types command writes). */
|
|
93
104
|
types() {
|
|
94
|
-
return this.
|
|
105
|
+
return this.request('GET', '/api/my-crm/v1/rows/types');
|
|
95
106
|
}
|
|
96
107
|
async query(kind, options = {}) {
|
|
97
|
-
const r = await this.
|
|
108
|
+
const r = await this.request('POST', '/api/my-crm/v1/rows/query', { kind, ...options });
|
|
98
109
|
return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
|
|
99
110
|
}
|
|
100
111
|
/** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. */
|
|
@@ -116,7 +127,7 @@ export class AwesomateClient {
|
|
|
116
127
|
async get(kind, id, options = {}) {
|
|
117
128
|
try {
|
|
118
129
|
const qs = options.tz ? `?tz=${encodeURIComponent(options.tz)}` : '';
|
|
119
|
-
const r = await this.
|
|
130
|
+
const r = await this.request('GET', `/api/my-crm/v1/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}${qs}`);
|
|
120
131
|
return r.row;
|
|
121
132
|
}
|
|
122
133
|
catch (err) {
|
|
@@ -127,27 +138,294 @@ export class AwesomateClient {
|
|
|
127
138
|
}
|
|
128
139
|
/** The app kinds this account has defined. */
|
|
129
140
|
async kinds() {
|
|
130
|
-
return (await this.
|
|
141
|
+
return (await this.request('GET', '/api/my-crm/v1/kinds')).kinds;
|
|
131
142
|
}
|
|
132
143
|
/** Define an app kind (a table an app keeps). No migration: registry rows and generated views. */
|
|
133
144
|
async defineKind(spec) {
|
|
134
|
-
return (await this.
|
|
145
|
+
return (await this.request('POST', '/api/my-crm/v1/kinds', spec)).kind;
|
|
135
146
|
}
|
|
136
147
|
/** Create a record (returns its id), or change one with options.id. Every value is checked against the kind. */
|
|
137
148
|
async write(kind, data, options = {}) {
|
|
138
|
-
const r = await this.
|
|
149
|
+
const r = await this.request('POST', '/api/my-crm/v1/call', {
|
|
139
150
|
fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
|
|
140
151
|
}, false);
|
|
141
152
|
return r.result.id;
|
|
142
153
|
}
|
|
143
154
|
/** Archive a record: it leaves every read and is kept for a restore. */
|
|
144
155
|
async archive(kind, id) {
|
|
145
|
-
await this.
|
|
156
|
+
await this.request('POST', '/api/my-crm/v1/call', { fn: 'archive_record', args: { kind, id } }, false);
|
|
157
|
+
}
|
|
158
|
+
/** The saved queries on this account. */
|
|
159
|
+
async queries() {
|
|
160
|
+
return (await this.request('GET', '/api/my-crm/v1/queries')).queries;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
|
|
164
|
+
* kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
|
|
165
|
+
* stored, so a column it cannot read is refused now rather than when it runs.
|
|
166
|
+
*/
|
|
167
|
+
async saveQuery(spec) {
|
|
168
|
+
return (await this.request('POST', '/api/my-crm/v1/queries', spec, false)).query;
|
|
169
|
+
}
|
|
170
|
+
async archiveQuery(key) {
|
|
171
|
+
await this.request('DELETE', `/api/my-crm/v1/queries/${encodeURIComponent(key)}`, undefined, false);
|
|
172
|
+
}
|
|
173
|
+
/** Run a saved query with its params. An optional param left out drops its condition. */
|
|
174
|
+
async run(query, params = {}, options = {}) {
|
|
175
|
+
const r = await this.request('POST', `/api/my-crm/v1/queries/${encodeURIComponent(query)}/run`, { params, ...options });
|
|
176
|
+
return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
|
|
177
|
+
}
|
|
178
|
+
/** The write recipes on this account. */
|
|
179
|
+
async recipes() {
|
|
180
|
+
return (await this.request('GET', '/api/my-crm/v1/recipes')).recipes;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
|
|
184
|
+
* {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
|
|
185
|
+
*/
|
|
186
|
+
async saveRecipe(spec) {
|
|
187
|
+
return (await this.request('POST', '/api/my-crm/v1/recipes', spec, false)).recipe;
|
|
188
|
+
}
|
|
189
|
+
async archiveRecipe(key) {
|
|
190
|
+
await this.request('DELETE', `/api/my-crm/v1/recipes/${encodeURIComponent(key)}`, undefined, false);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Run a write recipe. Every step commits or none does; a refusal names the step and the field.
|
|
194
|
+
* Never retried: a write that may have landed is not safe to send twice.
|
|
195
|
+
*/
|
|
196
|
+
async call(recipe, args = {}) {
|
|
197
|
+
return (await this.request('POST', '/api/my-crm/v1/call', { fn: recipe, args }, false)).result;
|
|
146
198
|
}
|
|
147
199
|
/** Grouped numbers (counts, sums) in the Business Data API's shape. */
|
|
148
200
|
aggregate(request) {
|
|
149
|
-
return this.
|
|
201
|
+
return this.request('POST', '/api/my-crm/v1/data/query', request);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function memoryStorage() {
|
|
205
|
+
const m = new Map();
|
|
206
|
+
return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => { m.set(k, v); }, removeItem: (k) => { m.delete(k); } };
|
|
207
|
+
}
|
|
208
|
+
function browserStorage() {
|
|
209
|
+
try {
|
|
210
|
+
if (typeof localStorage !== 'undefined') {
|
|
211
|
+
localStorage.getItem('awesomate.probe');
|
|
212
|
+
return localStorage;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch { /* storage blocked: a private window, a sandboxed frame */ }
|
|
216
|
+
return memoryStorage();
|
|
217
|
+
}
|
|
218
|
+
/** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
|
|
219
|
+
export function signInTokenFrom(url) {
|
|
220
|
+
const hash = url.includes('#') ? url.slice(url.indexOf('#') + 1) : '';
|
|
221
|
+
const v = new URLSearchParams(hash).get('awesomate_token');
|
|
222
|
+
return v && v.length >= 20 ? v : null;
|
|
223
|
+
}
|
|
224
|
+
const REFRESH_EARLY_MS = 30_000;
|
|
225
|
+
export class AwesomateAppClient {
|
|
226
|
+
opts;
|
|
227
|
+
base;
|
|
228
|
+
doFetch;
|
|
229
|
+
storage;
|
|
230
|
+
key;
|
|
231
|
+
session;
|
|
232
|
+
refreshing = null;
|
|
233
|
+
listeners = new Set();
|
|
234
|
+
constructor(opts) {
|
|
235
|
+
this.opts = opts;
|
|
236
|
+
if (!opts?.publishableKey?.startsWith('pk_'))
|
|
237
|
+
throw new Error('createAppClient needs the app\'s publishable key (pk_...).');
|
|
238
|
+
if (/^amt_(pat|bs)_/.test(opts.publishableKey))
|
|
239
|
+
throw new Error('That is the account\'s own token. Never put it in browser code; use the app\'s publishable key.');
|
|
240
|
+
this.base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '');
|
|
241
|
+
this.doFetch = opts.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
242
|
+
if (!this.doFetch)
|
|
243
|
+
throw new Error('No fetch available.');
|
|
244
|
+
this.storage = opts.storage ?? browserStorage();
|
|
245
|
+
this.key = opts.storageKey ?? `awesomate.session.${opts.publishableKey.slice(-10)}`;
|
|
246
|
+
}
|
|
247
|
+
auth = {
|
|
248
|
+
/** Email a sign-in link. The answer is the same whether or not the address can sign in. */
|
|
249
|
+
signInWithLink: (email, options) => this.request('POST', '/auth/magic-link', { email, redirect_to: options.redirectTo }),
|
|
250
|
+
/**
|
|
251
|
+
* Finish signing in from the link: the current page's address by default, or a URL a deep link
|
|
252
|
+
* opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
|
|
253
|
+
* token is removed from the browser's address bar.
|
|
254
|
+
*/
|
|
255
|
+
completeSignIn: async (url) => {
|
|
256
|
+
const here = typeof location !== 'undefined' ? location.href : '';
|
|
257
|
+
const token = signInTokenFrom(url ?? here);
|
|
258
|
+
if (!token)
|
|
259
|
+
return null;
|
|
260
|
+
if (!url && typeof history !== 'undefined' && typeof location !== 'undefined') {
|
|
261
|
+
history.replaceState(history.state, '', location.pathname + location.search);
|
|
262
|
+
}
|
|
263
|
+
return (await this.save(await this.request('POST', '/auth/verify', { token }))).user;
|
|
264
|
+
},
|
|
265
|
+
/** The signed-in user, or null. */
|
|
266
|
+
user: async () => (await this.load())?.user ?? null,
|
|
267
|
+
/** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
|
|
268
|
+
onChange: (listener) => {
|
|
269
|
+
this.listeners.add(listener);
|
|
270
|
+
return () => { this.listeners.delete(listener); };
|
|
271
|
+
},
|
|
272
|
+
signOut: async () => {
|
|
273
|
+
const s = await this.load();
|
|
274
|
+
await this.clear();
|
|
275
|
+
if (s)
|
|
276
|
+
await this.request('POST', '/auth/sign-out', { refresh_token: s.refresh_token }).catch(() => undefined);
|
|
277
|
+
},
|
|
278
|
+
/** A current access token, for the app's own server to verify against the JWKS. */
|
|
279
|
+
accessToken: async () => (await this.current())?.access_token ?? null,
|
|
280
|
+
};
|
|
281
|
+
async request(method, path, body, token) {
|
|
282
|
+
const res = await this.doFetch(`${this.base}/api/sdk/v1${path}`, {
|
|
283
|
+
method,
|
|
284
|
+
headers: {
|
|
285
|
+
'x-awesomate-key': this.opts.publishableKey,
|
|
286
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
287
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
288
|
+
},
|
|
289
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
290
|
+
});
|
|
291
|
+
if (res.status === 204)
|
|
292
|
+
return undefined;
|
|
293
|
+
const text = await res.text();
|
|
294
|
+
let json = {};
|
|
295
|
+
try {
|
|
296
|
+
json = text ? JSON.parse(text) : {};
|
|
297
|
+
}
|
|
298
|
+
catch { /* a proxy's error page */ }
|
|
299
|
+
if (res.ok)
|
|
300
|
+
return json;
|
|
301
|
+
const server = typeof json.code === 'string' ? json.code : undefined;
|
|
302
|
+
const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
|
|
303
|
+
throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
|
|
304
|
+
}
|
|
305
|
+
async read() {
|
|
306
|
+
try {
|
|
307
|
+
const raw = await this.storage.getItem(this.key);
|
|
308
|
+
const s = raw ? JSON.parse(raw) : null;
|
|
309
|
+
return s?.access_token && s.refresh_token && s.user ? s : null;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
150
314
|
}
|
|
315
|
+
async load() {
|
|
316
|
+
if (this.session === undefined)
|
|
317
|
+
this.session = await this.read();
|
|
318
|
+
return this.session;
|
|
319
|
+
}
|
|
320
|
+
async save(r) {
|
|
321
|
+
const s = { access_token: r.access_token, refresh_token: r.refresh_token, expires_at: Date.now() + r.expires_in * 1000, user: r.user };
|
|
322
|
+
this.session = s;
|
|
323
|
+
await this.storage.setItem(this.key, JSON.stringify(s));
|
|
324
|
+
for (const l of this.listeners)
|
|
325
|
+
l(s.user);
|
|
326
|
+
return s;
|
|
327
|
+
}
|
|
328
|
+
async clear() {
|
|
329
|
+
const had = this.session !== null;
|
|
330
|
+
this.session = null;
|
|
331
|
+
await this.storage.removeItem(this.key);
|
|
332
|
+
if (had)
|
|
333
|
+
for (const l of this.listeners)
|
|
334
|
+
l(null);
|
|
335
|
+
}
|
|
336
|
+
/** A session whose access token has at least half a minute left, refreshing if not. */
|
|
337
|
+
async current() {
|
|
338
|
+
const s = await this.load();
|
|
339
|
+
if (!s)
|
|
340
|
+
return null;
|
|
341
|
+
return s.expires_at - Date.now() > REFRESH_EARLY_MS ? s : this.refresh(s);
|
|
342
|
+
}
|
|
343
|
+
/** One refresh at a time per client; a refresh another tab already did is picked up from storage. */
|
|
344
|
+
refresh(s) {
|
|
345
|
+
if (this.refreshing)
|
|
346
|
+
return this.refreshing;
|
|
347
|
+
this.refreshing = (async () => {
|
|
348
|
+
const stored = await this.read();
|
|
349
|
+
if (stored && stored.refresh_token !== s.refresh_token && stored.expires_at - Date.now() > REFRESH_EARLY_MS) {
|
|
350
|
+
this.session = stored;
|
|
351
|
+
return stored;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
return await this.save(await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token }));
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
|
|
358
|
+
throw err;
|
|
359
|
+
const after = await this.read();
|
|
360
|
+
if (after && after.refresh_token !== s.refresh_token) {
|
|
361
|
+
this.session = after;
|
|
362
|
+
return after;
|
|
363
|
+
}
|
|
364
|
+
await this.clear();
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
})().finally(() => { this.refreshing = null; });
|
|
368
|
+
return this.refreshing;
|
|
369
|
+
}
|
|
370
|
+
/** A data call as the signed-in user: refused once (a disable, a key change), it refreshes and tries again; refused twice, it signs out. */
|
|
371
|
+
async data(method, path, body) {
|
|
372
|
+
let s = await this.current();
|
|
373
|
+
if (!s)
|
|
374
|
+
throw new AwesomateError('unauthenticated', 'Sign in first.', 401);
|
|
375
|
+
try {
|
|
376
|
+
return await this.request(method, path, body, s.access_token);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
|
|
380
|
+
throw err;
|
|
381
|
+
s = await this.refresh(s);
|
|
382
|
+
if (!s)
|
|
383
|
+
throw err;
|
|
384
|
+
return this.request(method, path, body, s.access_token);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async query(kind, options = {}) {
|
|
388
|
+
const r = await this.data('POST', '/rows/query', { kind, ...options });
|
|
389
|
+
return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, asAt: r.as_at };
|
|
390
|
+
}
|
|
391
|
+
async *queryAll(kind, options = {}) {
|
|
392
|
+
const { maxRows = 10_000, ...rest } = options;
|
|
393
|
+
let after;
|
|
394
|
+
let seen = 0;
|
|
395
|
+
do {
|
|
396
|
+
const page = await this.query(kind, { limit: 1000, ...rest, ...(after ? { after } : {}) });
|
|
397
|
+
for (const row of page.rows) {
|
|
398
|
+
if (seen++ >= maxRows)
|
|
399
|
+
return;
|
|
400
|
+
yield row;
|
|
401
|
+
}
|
|
402
|
+
after = page.next ?? undefined;
|
|
403
|
+
} while (after);
|
|
404
|
+
}
|
|
405
|
+
/** One record by id, or null when there is none this user may read. */
|
|
406
|
+
async get(kind, id) {
|
|
407
|
+
try {
|
|
408
|
+
return (await this.data('GET', `/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`)).row;
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
if (err instanceof AwesomateError && err.code === 'not_found')
|
|
412
|
+
return null;
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
|
|
417
|
+
async write(kind, data, options = {}) {
|
|
418
|
+
const r = await this.data('POST', '/call', {
|
|
419
|
+
fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
|
|
420
|
+
});
|
|
421
|
+
return r.result.id;
|
|
422
|
+
}
|
|
423
|
+
async archive(kind, id) {
|
|
424
|
+
await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
export function createAppClient(options) {
|
|
428
|
+
return new AwesomateAppClient(options);
|
|
151
429
|
}
|
|
152
430
|
export function createClient(options) {
|
|
153
431
|
return new AwesomateClient(options);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"publishConfig": {
|