@lotics/app-sdk 0.68.0 → 0.70.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.
@@ -103,6 +103,18 @@ interface PaginatedQueryState<R> extends QueryStateBase {
103
103
  export interface QuerySortKey {
104
104
  field_key: string;
105
105
  order: "asc" | "desc";
106
+ /**
107
+ * Where rows BLANK in this column sit — `"bottom"` when omitted, which is what
108
+ * you want for "the ones with a value first".
109
+ *
110
+ * It earns its place on a MULTI-KEY sort over columns that are blank by
111
+ * position rather than by accident — a pipeline's per-step date stamps, say,
112
+ * where the first non-blank column IS the row's rank. Reading such a ladder
113
+ * from the top with blanks at the bottom orders it furthest-along-first; the
114
+ * reverse reading needs blanks on TOP, and with only the default there is no
115
+ * way to express it, so the column can be sorted one way and not the other.
116
+ */
117
+ blank_position?: "top" | "bottom";
106
118
  }
107
119
  /** A filter condition over one output column (wire shape of a filter node). */
108
120
  export interface QueryFilterCondition {
@@ -9,6 +9,20 @@
9
9
  *
10
10
  * If the wire format changes, the resolver and this reader move together.
11
11
  */
12
+ /**
13
+ * A member, in the ONE shape every door returns — a `select_member` cell in a
14
+ * `useQuery` row and the `useMembers` roster alike.
15
+ *
16
+ * The two used to disagree: the roster carried an avatar, a resolved cell did
17
+ * not, and both were typed `ResolvedMember`, so an app author holding one could
18
+ * not tell which. That is why no register row ever rendered an avatar — the
19
+ * data was absent and nothing said so.
20
+ *
21
+ * The private fields share ONE boundary, not three: an authenticated member of
22
+ * the app's own org sees `email`, `image` and `groups`; an anonymous visitor to
23
+ * a public app sees `id` and `name`. Absent ≠ empty — `image: null` means the
24
+ * member has no photo, `image` MISSING means you were never told.
25
+ */
12
26
  export interface ResolvedMember {
13
27
  id: string;
14
28
  /** `null` when the id resolves outside the app's org (e.g. removed
@@ -18,9 +32,14 @@ export interface ResolvedMember {
18
32
  /** Present only on authenticated responses. Omitted on public-app
19
33
  * responses (no PII exposure to anonymous visitors). */
20
34
  email?: string | null;
21
- /** Avatar URL. Present on the `useMembers` roster (may be null when the
22
- * member has no profile image); absent on resolved `select_member` cells. */
35
+ /** Avatar URL, already presigned render it directly. `null` when the member
36
+ * has no profile image (the common case: photos are opt-in). Omitted on
37
+ * public-app responses. */
23
38
  image?: string | null;
39
+ /** The member's group names — the platform's "department". There is no
40
+ * department field; a member group is what an org uses to say Sale, Kế toán,
41
+ * CSKH. `[]` when the member is in none. Omitted on public-app responses. */
42
+ groups?: string[];
24
43
  }
25
44
  /**
26
45
  * Parse a `useQuery` cell value into `ResolvedMember[]`. Returns `[]` for
@@ -28,13 +28,18 @@ export function readMembers(value) {
28
28
  continue;
29
29
  const name = typeof obj.name === "string" ? obj.name : obj.name === null ? null : null;
30
30
  const m = { id, name };
31
- if ("email" in obj) {
32
- m.email =
33
- typeof obj.email === "string"
34
- ? obj.email
35
- : obj.email === null
36
- ? null
37
- : null;
31
+ // PRESENT copy, ABSENT ⇒ leave off. The distinction is the permission
32
+ // boundary itself: a missing key means a public reader was never told,
33
+ // while `null` means the member genuinely has none. Defaulting either to
34
+ // null here would erase that and make a gated field look like an empty one.
35
+ if ("email" in obj)
36
+ m.email = typeof obj.email === "string" ? obj.email : null;
37
+ if ("image" in obj)
38
+ m.image = typeof obj.image === "string" ? obj.image : null;
39
+ if ("groups" in obj) {
40
+ m.groups = Array.isArray(obj.groups)
41
+ ? obj.groups.filter((g) => typeof g === "string")
42
+ : [];
38
43
  }
39
44
  out.push(m);
40
45
  }
@@ -240,7 +240,7 @@ Wire shapes per output column type:
240
240
  | `date` | `"YYYY-MM-DD"` — a calendar day, no time component (a reduced-precision date field may also carry `"YYYY-MM"` or `"YYYY"`; see below) |
241
241
  | `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
242
242
  | `select` | `Array<{ key, label }>` — one entry per selected option |
243
- | `select_member` | `Array<{ id, name, email? }>` — `email` only for authenticated viewers of the app's own org |
243
+ | `select_member` | `Array<{ id, name, email?, image?, groups? }>` — the last three only for authenticated viewers of the app's own org; `image` presigned |
244
244
  | `select_record_link` | `Array<{ id, display }>` — target record id + its display text |
245
245
  | `files` | `Array<{ id, filename, mime_type, url, thumbnail_url?, size?, created_at? }>` — presigned |
246
246
 
@@ -268,7 +268,7 @@ emits none of these" is a fact the type states rather than one you have to remem
268
268
  | `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
269
269
  | `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
270
270
  | `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
271
- | `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email? }[]` (`[]` when empty) |
271
+ | `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email?, image?, groups? }[]` (`[]` when empty) |
272
272
  | `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
273
273
  | `readLocked(rowObj)` | the whole **row** → `boolean` | the record's lock state from `__source_locked` |
274
274
 
@@ -16,8 +16,8 @@ Every select and member value reaches the app in one of two shapes, and most scr
16
16
  | --- | --- | --- |
17
17
  | Render a stored select value | `readSelect(cell)` | The options the record actually holds — `{ key, label }`, **no color** |
18
18
  | Populate a select picker, or color a stored value | `useFieldOptions(alias)` | Each select column's **complete** option list — `{ key, label, color }` — plus a `byKey` index |
19
- | Render a stored member value | `readMembers(cell)` | The members the record actually holds — `{ id, name, email? }`, **no avatar** |
20
- | Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — `{ id, name, email, image }` (avatar included) |
19
+ | Render a stored member value | `readMembers(cell)` | The members the record actually holds |
20
+ | Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — every member, not only the referenced ones |
21
21
 
22
22
  Cells are **self-describing**: the server rewrites raw storage shapes into resolved objects before
23
23
  rows reach the app, so an app never maintains a hardcoded key→label or id→name map. Catalogs are
@@ -108,18 +108,24 @@ cell's own `{ key, label }`, which renders as a neutral badge. Never hand-map op
108
108
  ## Member cells (`readMembers`)
109
109
 
110
110
  A `select_member` column's storage shape is a bare array of member ids. The server rewrites every
111
- projected `select_member` cell to `Array<{ id, name, email? }>`:
112
-
113
- - **`email` is present only for authenticated members of the app's own organization.** Anonymous
114
- visitors to a public app — and members of *other* orgs viewing a publicly shared app get
115
- `{ id, name }` only. No PII crosses to viewers outside the org.
116
- - **An id that no longer resolves** (removed member, id outside the org) comes back as
117
- `{ id, name: null }` — an explicit missing state, never an empty string. Render a placeholder.
118
- - **Cells never carry the avatar.** `ResolvedMember.image` exists only on the `useMembers` roster;
119
- avatar URLs are presigned files and stay in that one bounded list rather than being fattened onto
120
- every query cell.
111
+ projected `select_member` cell to `Array<{ id, name, email?, image?, groups? }>` — **the same shape
112
+ `useMembers` returns**, so a member is a member wherever you got them from:
113
+
114
+ - **`email`, `image` and `groups` share ONE gate.** All three are present only for authenticated
115
+ members of the app's own organization. Anonymous visitors to a public app — and members of *other*
116
+ orgs viewing a publicly shared app get `{ id, name }` only. A face and a team are org-internal
117
+ exactly as an address is.
118
+ - **Absent is not null.** A missing `image` key means you were not told; `image: null` means the
119
+ member genuinely has no photo. Same for `groups`: absent vs `[]`. Rendering the two the same way
120
+ turns a permission boundary into a missing-data bug.
121
+ - **`image` is already presigned** — render it directly. It is a `/v1/files/…` object behind a
122
+ cookie-authenticated proxy, so an unsigned URL would not load inside an app iframe at all.
123
+ - **`groups` is the platform's "department".** There is no department field; a member group is what
124
+ an org uses to say Sale, Kế toán, CSKH. `[]` when the member is in none.
125
+ - **An id that no longer resolves** (removed member, id outside the org) comes back with
126
+ `name: null` — an explicit missing state, never an empty string. Render a placeholder.
121
127
  - Only ids already present in the projected rows are resolved — a member cell never exposes the
122
- wider directory.
128
+ wider directory, and only their avatars are signed.
123
129
 
124
130
  Decode with **`readMembers(cell)`** (`dist/src/members.d.ts`) → `ResolvedMember[]`. Same defensive
125
131
  contract as `readSelect`: `[]` for null/empty/unexpected cells, malformed entries dropped.
@@ -133,9 +139,9 @@ const { members, loading, error } = useMembers({ group: "grp_fulfillment" });
133
139
  <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
134
140
  ```
135
141
 
136
- Each member is `{ id, name, email, image }`. `image` is the avatar URL (a presigned URL valid 24
137
- hours, or the member's external OAuth photo) and may be `null`. `name` may be null/empty for
138
- members without a display name — fall back to `email`. On failure the hook does not throw: it
142
+ Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups }`.
143
+ `image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
144
+ may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
139
145
  resolves `{ members: [], loading: false, error }`.
140
146
 
141
147
  **Limitation:** `useMembers` is not SWR-cached — every mounted hook instance issues its own
@@ -294,7 +300,7 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
294
300
  | Value | Component | Feed it |
295
301
  | --- | --- | --- |
296
302
  | A select value (stored or picker option) | `OptionBadge` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
297
- | A person, inline | `MemberChip` | `name` / `image` resolved from the roster or a cell; no image → initials |
303
+ | A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
298
304
  | A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
299
305
  | A comment thread | `CommentList` + `CommentComposer` | `useComments` state; `resolveMember` bridges `member_id` → your member source |
300
306
 
@@ -305,7 +311,7 @@ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter
305
311
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
306
312
  | --- | --- | --- |
307
313
  | `readSelect` cell enrichment | ✓ | ✓ |
308
- | `readMembers` cell enrichment | ✓ (email included, same-org) | ✓ name-only, no email |
314
+ | `readMembers` cell enrichment | ✓ (email + image + groups, same-org) | ✓ name-only |
309
315
  | `useFieldOptions` | ✓ | ✓ |
310
316
  | `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
311
317
  | `useViewer` | member id (view-as target) | `null` |
package/docs/queries.md CHANGED
@@ -124,8 +124,10 @@ Delivery-layer enrichment (applied to the response, per request):
124
124
  server-bounded (§10). Public apps hand out direct presigned URLs — anonymous-fetchable,
125
125
  time-boxed. (App workflow-execute responses presign returned files the same way, so their
126
126
  URLs also work for anonymous public-app viewers — see [files.md](./files.md).)
127
- - **`select_member` cells** — bare member-id arrays become `{ id, name, email? }[]` (`email`
128
- only for authenticated members of the app's own org).
127
+ - **`select_member` cells** — bare member-id arrays become
128
+ `{ id, name, email?, image?, groups? }[]`, the same shape `useMembers` returns. The last three
129
+ are org-internal and share one gate: present only for authenticated members of the app's own org,
130
+ and `image` arrives presigned.
129
131
  - **`select` cells** — bare option-key arrays become `{ key, label }[]` (colors ride in
130
132
  `useFieldOptions`, not the cell — see [members_and_options.md](./members_and_options.md)).
131
133
  **Enrichment needs the column's source addressing**, which a passthrough projection carries, a
@@ -768,10 +770,13 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
768
770
  it. Record-link membership (`has_any_of` by id) **works** here; so does `is_current_member`
769
771
  on a member column. Traversals / `locked` / `current_member` do not — bake those into the
770
772
  template.
771
- - **`sort`** — `[{ field_key, order }]` over output columns — the typed `QuerySortKey` the hooks
772
- accept. The wire sort node also honors `blank_position?: "top" | "bottom"` (blanks default to
773
- bottom), but that field is **not** on the typed hook option; reach it only through a raw
774
- `rpc("query", { sort })` call.
773
+ - **`sort`** — `[{ field_key, order, blank_position? }]` over output columns — the typed
774
+ `QuerySortKey` the hooks accept. `blank_position` is `"top" | "bottom"`, defaulting to
775
+ `"bottom"`. It matters on a **multi-key** sort over columns whose blankness IS the ranking
776
+ a pipeline's per-step date stamps, where the first non-blank column gives the row's rung.
777
+ Listing the ladder top-down with blanks at the bottom orders it furthest-along-first; the
778
+ ascending reading needs `blank_position: "top"` on every key, and without it that direction
779
+ cannot be expressed at all.
775
780
  - **`limit` / `offset` / keyset `cursor`** — pagination. `useQuery` / `usePaginatedQuery` page by
776
781
  `offset` (`limit` clamped to the 10,000-row cap, §10); `useInfiniteQuery` opts into **keyset
777
782
  (seek)** by sending `keyset: true` + the prior page's `cursor`, and the server returns the next
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.68.0",
4
- "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.70.0",
4
+ "description": "Runtime SDK for Lotics custom-code apps typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {