@benmore/bm 2.7.310 → 2.7.312

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.
Files changed (3) hide show
  1. package/index.d.ts +33 -2
  2. package/index.js +85 -41
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -5,6 +5,10 @@ export interface User {
5
5
  last_name?: string | null;
6
6
  role: string;
7
7
  verified: number;
8
+ /** Every role the user holds: global roles plus the current tenant's roles. */
9
+ roles?: string[];
10
+ /** A groups: app's current tenant id (an admin's act-as tenant while stepped in); null without one. */
11
+ group_id?: string | null;
8
12
  [field: string]: unknown;
9
13
  }
10
14
 
@@ -16,6 +20,16 @@ export interface ListOpts {
16
20
  /** Column to order by (snake_case, as stored). */
17
21
  sort?: string;
18
22
  order?: 'asc' | 'desc';
23
+ /** REST shorthand for sort + order: 'starts_at:asc'. Ignored when sort is set. */
24
+ orderBy?: string;
25
+ /** Related rows to embed (?include=): 'author' or ['author', 'comments']. */
26
+ include?: string | string[];
27
+ /** Columns to return (?fields=). */
28
+ fields?: string | string[];
29
+ /** Page number for page/per_page pagination (list() still returns the rows only). */
30
+ page?: number;
31
+ per_page?: number;
32
+ /** Equality filters (col: value) and operator filters (col__gte: value). Other top-level keys are not sent. */
19
33
  where?: Record<string, string | number | boolean>;
20
34
  }
21
35
 
@@ -131,11 +145,28 @@ export interface RoomClient {
131
145
  readonly raw: WebSocket;
132
146
  }
133
147
 
148
+ /** POST /api/<table>/<id>/transition answer. */
149
+ export interface WorkflowTransitionResult {
150
+ status: 'transitioned';
151
+ from: string;
152
+ to: string;
153
+ /** The row id as it appeared in the URL (a string). */
154
+ id: string;
155
+ }
156
+
157
+ /** GET /api/<table>/<id>/transitions answer: the row's state and the moves this caller may make. */
158
+ export interface WorkflowAvailable {
159
+ current: string;
160
+ /** role is set when the transition is role-gated (the caller holds it). */
161
+ transitions: Array<{ to: string; role?: string }>;
162
+ }
163
+
134
164
  export interface WorkflowClient {
135
165
  readonly table: string;
136
166
  readonly id: number | string;
137
- transitionTo(state: string): Promise<{ state: string }>;
138
- available(): Promise<string[]>;
167
+ transitionTo(state: string): Promise<WorkflowTransitionResult>;
168
+ available(): Promise<WorkflowAvailable>;
169
+ /** The state stored in the workflow's own field (workflows.yaml field:). */
139
170
  current(): Promise<string>;
140
171
  }
141
172
 
package/index.js CHANGED
@@ -48,16 +48,30 @@ async function request(method, path, body, options) {
48
48
  return { ok: res.ok, status: res.status, data };
49
49
  }
50
50
 
51
+ // apiError builds the Error a failed request throws. The framework's JSON
52
+ // errors carry a short code in `error` and, when there is more to say, the
53
+ // explanation in `message` (a refused write, a flow that failed): the Error's
54
+ // message is that explanation, else the code, else fallback, and err.code is
55
+ // the code. err.status and err.body are the response as received.
56
+ function apiError(res, fallback) {
57
+ const data = res.data;
58
+ let text = fallback;
59
+ let code;
60
+ if (data && typeof data === 'object') {
61
+ if (typeof data.error === 'string' && data.error) { code = data.error; text = data.error; }
62
+ if (typeof data.message === 'string' && data.message) text = data.message;
63
+ }
64
+ const err = new Error(text);
65
+ err.status = res.status;
66
+ err.body = data;
67
+ if (code) err.code = code;
68
+ return err;
69
+ }
70
+
51
71
  // Convenience: unwrap or throw. Most agent code wants this shape:
52
72
  // const message = await bm.api.post('/api/messages', {...});
53
73
  async function unwrap(res) {
54
- if (!res.ok) {
55
- const msg = res.data?.error || `HTTP ${res.status}`;
56
- const err = new Error(msg);
57
- err.status = res.status;
58
- err.body = res.data;
59
- throw err;
60
- }
74
+ if (!res.ok) throw apiError(res, `HTTP ${res.status}`);
61
75
  return res.data;
62
76
  }
63
77
 
@@ -185,12 +199,7 @@ async function refreshMe() {
185
199
  // Returns the user object on success; throws { status, body } on failure.
186
200
  async function signIn(identifier, password) {
187
201
  const res = await request('POST', '/login', { email: identifier, password });
188
- if (!res.ok) {
189
- const err = new Error(res.data?.error || 'Invalid credentials');
190
- err.status = res.status;
191
- err.body = res.data;
192
- throw err;
193
- }
202
+ if (!res.ok) throw apiError(res, 'Invalid credentials');
194
203
  return res.data?.user || null;
195
204
  }
196
205
 
@@ -200,10 +209,7 @@ async function signIn(identifier, password) {
200
209
  async function signUp(fields) {
201
210
  const res = await request('POST', '/signup', fields);
202
211
  if (!res.ok) {
203
- const err = new Error(res.data?.error || 'Sign-up failed');
204
- err.status = res.status;
205
- err.body = res.data;
206
- throw err;
212
+ throw apiError(res, 'Sign-up failed');
207
213
  }
208
214
  return res.data?.user || null;
209
215
  }
@@ -234,10 +240,7 @@ function _bufToB64u(buf) {
234
240
  async function _passkeyCall(method, path, body, fallback) {
235
241
  const res = await request(method, path, body);
236
242
  if (!res.ok) {
237
- const err = new Error(res.data?.error || fallback);
238
- err.status = res.status;
239
- err.body = res.data;
240
- throw err;
243
+ throw apiError(res, fallback);
241
244
  }
242
245
  return res.data;
243
246
  }
@@ -368,6 +371,21 @@ function _seg(v) {
368
371
  return e;
369
372
  }
370
373
 
374
+ // list() options it sends. Anything else (a filter written at the top level
375
+ // instead of under where:, a misspelled option) never reaches the server, so
376
+ // say so once per table and key instead of returning unfiltered rows quietly.
377
+ const _LIST_OPTS = new Set(['limit', 'offset', 'cursor', 'q', 'sort', 'order', 'orderBy', 'order_by',
378
+ 'include', 'fields', 'page', 'per_page', 'perPage', 'where']);
379
+ const _warnedListOpts = new Set();
380
+ function _warnUnknownListOpts(table, opts) {
381
+ for (const k of Object.keys(opts || {})) {
382
+ if (_LIST_OPTS.has(k) || _warnedListOpts.has(table + '\u0000' + k)) continue;
383
+ _warnedListOpts.add(table + '\u0000' + k);
384
+ console.warn(`bm.table('${table}').list(): option "${k}" is not sent. ` +
385
+ `Filters go under where: { ${k}: ... }; list options are ${[..._LIST_OPTS].join(', ')}.`);
386
+ }
387
+ }
388
+
371
389
  export function table(name) {
372
390
  if (!name || typeof name !== 'string') {
373
391
  throw new Error('bm.table(name) requires a string table name');
@@ -386,9 +404,21 @@ export function table(name) {
386
404
  if (opts.q) qs.set('q', String(opts.q));
387
405
  if (opts.sort) qs.set('sort', String(opts.sort));
388
406
  if (opts.order) qs.set('order', String(opts.order));
407
+ // The REST list parameters under their REST names: orderBy=col:dir,
408
+ // include=rel,rel, page/per_page, fields=a,b. They used to be dropped
409
+ // here without a word, so the server never saw them.
410
+ const orderBy = opts.orderBy ?? opts.order_by;
411
+ if (orderBy) qs.set('orderBy', String(orderBy));
412
+ const csv = (v) => (Array.isArray(v) ? v.join(',') : String(v));
413
+ if (opts.include) qs.set('include', csv(opts.include));
414
+ if (opts.fields) qs.set('fields', csv(opts.fields));
415
+ if (opts.page != null) qs.set('page', String(opts.page));
416
+ const perPage = opts.per_page ?? opts.perPage;
417
+ if (perPage != null) qs.set('per_page', String(perPage));
389
418
  if (opts.where) {
390
419
  for (const [k, v] of Object.entries(opts.where)) qs.set(k, String(v));
391
420
  }
421
+ _warnUnknownListOpts(t, opts);
392
422
  const path = base + (qs.toString() ? '?' + qs : '');
393
423
  const r = await api.get(path);
394
424
  // Offset lists are bare arrays; cursor/page lists use { data: [...] }.
@@ -717,12 +747,22 @@ async function loadFlowsRegistry() {
717
747
  return _flowsRegistry;
718
748
  }
719
749
 
720
- // Registry keys are camelCase (company_setup / company-setup → companySetup).
721
- // Accept snake_case and kebab-case aliases too - agents naturally write
722
- // bm.flows.company_setup when the file is flows/company-setup.yaml, and
723
- // making the obvious spelling work beats making them learn the mapping.
750
+ // Registry keys are camelCase (company_setup / company-setup → companySetup;
751
+ // bm_types.go flowSDKName is the same rule). Accept snake_case and
752
+ // kebab-case spellings too - agents naturally write bm.flows.company_setup
753
+ // when the file is flows/company-setup.yaml, and making the obvious spelling
754
+ // work beats making them learn the mapping.
724
755
  function camelizeFlowName(name) {
725
- return name.replace(/[-_]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
756
+ const parts = String(name).split(/[-_]+/).filter(Boolean);
757
+ if (!parts.length) return name;
758
+ return parts.map((p, i) => (i === 0 ? p.charAt(0).toLowerCase() : p.charAt(0).toUpperCase()) + p.slice(1)).join('');
759
+ }
760
+
761
+ // Own keys only: a name such as 'constructor' must not resolve through the
762
+ // prototype chain of the parsed registry.
763
+ function flowRegistryEntry(reg, name) {
764
+ const own = (k) => (Object.prototype.hasOwnProperty.call(reg, k) ? reg[k] : undefined);
765
+ return own(name) || own(camelizeFlowName(name));
726
766
  }
727
767
 
728
768
  export const flows = new Proxy({}, {
@@ -730,19 +770,20 @@ export const flows = new Proxy({}, {
730
770
  if (typeof flowName !== 'string') return undefined;
731
771
  return async (params = {}, body) => {
732
772
  let reg = await loadFlowsRegistry();
733
- let entry = reg[flowName] || reg[camelizeFlowName(flowName)];
773
+ let entry = flowRegistryEntry(reg, flowName);
734
774
  if (!entry) {
735
775
  // The registry lists only flows THIS caller may call, so one loaded
736
776
  // before sign-in (or before a role grant) can be missing the flow.
737
777
  // Refetch once before reporting it as undeclared.
738
778
  _flowsRegistry = null;
739
779
  reg = await loadFlowsRegistry();
740
- entry = reg[flowName] || reg[camelizeFlowName(flowName)];
780
+ entry = flowRegistryEntry(reg, flowName);
741
781
  }
742
782
  if (!entry) {
743
783
  const available = Object.keys(reg).sort();
784
+ const call = (n) => (/^[A-Za-z_$][\w$]*$/.test(n) ? `bm.flows.${n}()` : `bm.flows[${JSON.stringify(n)}]()`);
744
785
  const listing = available.length
745
- ? `Available flows: ${available.map(n => `bm.flows.${n}()`).join(', ')}.`
786
+ ? `Available flows: ${available.map(call).join(', ')}.`
746
787
  : 'No flows are registered (flows.yaml / flows/*.yaml define none with on.request triggers).';
747
788
  throw new Error(
748
789
  `bm.flows.${flowName} is not declared in flows.yaml. ${listing} ` +
@@ -757,11 +798,7 @@ export const flows = new Proxy({}, {
757
798
  }
758
799
  const method = (entry.method || 'POST').toUpperCase();
759
800
  const res = await request(method, path, body);
760
- if (!res.ok) {
761
- const err = new Error(res.data?.error || `flow ${flowName} failed (${res.status})`);
762
- err.status = res.status; err.body = res.data;
763
- throw err;
764
- }
801
+ if (!res.ok) throw apiError(res, `flow ${flowName} failed (${res.status})`);
765
802
  // Async flows return {job_id, status_url} - wrap into a Job handle.
766
803
  if (res.data && res.data.job_id && res.data.status_url) {
767
804
  return makeJob(res.data.job_id, res.data.status_url);
@@ -786,12 +823,22 @@ export function workflow(table, id) {
786
823
  return {
787
824
  table,
788
825
  id,
789
- // Server expects { to: <newState> } (workflows.go handleTransition).
826
+ // Server expects { to: <newState> } (workflows.go handleTransition) and
827
+ // answers { status: 'transitioned', from, to, id }.
790
828
  transitionTo: (state) => api.post(`${base}/transition`, { to: state }),
829
+ // { current, transitions: [{ to, role? }] } (handleAvailableTransitions).
791
830
  available: () => api.get(`${base}/transitions`),
831
+ // The transitions endpoint reads the workflow's own field (workflows.yaml
832
+ // field:), whatever it is named; reading row.state ?? row.status missed
833
+ // any other field. It needs a signed-in caller, so an anonymous reader of
834
+ // a public table falls back to the row.
792
835
  current: async () => {
793
- const row = await api.get(base);
794
- return row.state ?? row.status;
836
+ const res = await request('GET', `${base}/transitions`);
837
+ if (res.status === 401) {
838
+ const row = await api.get(base);
839
+ return row?.state ?? row?.status;
840
+ }
841
+ return (await unwrap(res))?.current;
795
842
  },
796
843
  };
797
844
  }
@@ -1119,10 +1166,7 @@ function waitForICEGathering(pc) {
1119
1166
  async function broadcastSDPExchange(path, slug, sdpOffer) {
1120
1167
  const res = await request('POST', path, { slug, sdp: sdpOffer });
1121
1168
  if (!res.ok) {
1122
- const err = new Error(res.data?.error || 'broadcast: SDP exchange failed');
1123
- err.status = res.status;
1124
- err.body = res.data;
1125
- throw err;
1169
+ throw apiError(res, 'broadcast: SDP exchange failed');
1126
1170
  }
1127
1171
  return res.data; // { sdp, session_id, slug }
1128
1172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benmore/bm",
3
- "version": "2.7.310",
3
+ "version": "2.7.312",
4
4
  "description": "Zero-dependency browser SDK for external Benmore frontends",
5
5
  "type": "module",
6
6
  "module": "./index.js",