@lotics/app-sdk 0.97.0 → 0.98.1

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/AGENTS.md CHANGED
@@ -19,7 +19,7 @@ signature; open the file.**
19
19
  | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` and `row.bool` answer `null` for an EMPTY cell, so none is distinguishable from zero and an unanswered checkbox from a "no" — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
20
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
21
21
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
22
- | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
22
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted; and a LOOKUP of a files field is one list per linked row, which the reader opens itself), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
23
23
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useComments` (each comment carries its own resolved `author`, so a thread crossing a role boundary is legible without declaring member access), and the `@lotics/ui` components they feed. |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
package/dist/src/row.d.ts CHANGED
@@ -116,6 +116,15 @@ export interface AppFile {
116
116
  * an unservable file. Pass one to `toDisplayFile` from `@lotics/ui/file_thumbnail`
117
117
  * for FileThumbnail/Gallery — the kit owns that conversion, so don't re-declare
118
118
  * it per app.
119
+ *
120
+ * A LOOKUP OF A FILES FIELD ARRIVES ONE LEVEL DEEPER, exactly as a lookup of a
121
+ * link field does (see {@link readLinks}) — the cell holds one entry per linked
122
+ * record and each entry is THAT record's whole files cell, an array — so read
123
+ * entry-by-entry it decoded to nothing while the pictures were plainly there,
124
+ * and every mark drawn off a looked-up photograph fell back to its glyph. The
125
+ * nesting is flattened here, at the boundary that owns the serialization, rather
126
+ * than at each caller: one level, in the order the rows link, and a file two
127
+ * linked rows both carry is one file, so ids repeat at most once.
119
128
  */
120
129
  export declare function readFiles(v: unknown): AppFile[];
121
130
  /**
package/dist/src/row.js CHANGED
@@ -162,32 +162,46 @@ export function readLinks(v) {
162
162
  * an unservable file. Pass one to `toDisplayFile` from `@lotics/ui/file_thumbnail`
163
163
  * for FileThumbnail/Gallery — the kit owns that conversion, so don't re-declare
164
164
  * it per app.
165
+ *
166
+ * A LOOKUP OF A FILES FIELD ARRIVES ONE LEVEL DEEPER, exactly as a lookup of a
167
+ * link field does (see {@link readLinks}) — the cell holds one entry per linked
168
+ * record and each entry is THAT record's whole files cell, an array — so read
169
+ * entry-by-entry it decoded to nothing while the pictures were plainly there,
170
+ * and every mark drawn off a looked-up photograph fell back to its glyph. The
171
+ * nesting is flattened here, at the boundary that owns the serialization, rather
172
+ * than at each caller: one level, in the order the rows link, and a file two
173
+ * linked rows both carry is one file, so ids repeat at most once.
165
174
  */
166
175
  export function readFiles(v) {
167
176
  if (!Array.isArray(v))
168
177
  return [];
169
178
  const out = [];
170
- for (const f of v) {
171
- if (!f || typeof f !== "object")
172
- continue;
173
- const id = f.id;
174
- const url = f.url;
175
- if (typeof id !== "string" || !id || typeof url !== "string" || !url)
176
- continue;
177
- const filename = f.filename;
178
- const mime = f.mime_type;
179
- const thumb = f.thumbnail_url;
180
- const size = f.size;
181
- const created_at = f.created_at;
182
- out.push({
183
- id,
184
- filename: typeof filename === "string" ? filename : "",
185
- mime_type: typeof mime === "string" ? mime : "",
186
- url,
187
- thumbnail_url: typeof thumb === "string" ? thumb : undefined,
188
- size: typeof size === "number" ? size : undefined,
189
- created_at: typeof created_at === "string" ? created_at : undefined,
190
- });
179
+ const seen = new Set();
180
+ for (const entry of v) {
181
+ const held = Array.isArray(entry) ? entry : [entry];
182
+ for (const f of held) {
183
+ if (!f || typeof f !== "object")
184
+ continue;
185
+ const id = f.id;
186
+ const url = f.url;
187
+ if (typeof id !== "string" || !id || typeof url !== "string" || !url || seen.has(id))
188
+ continue;
189
+ seen.add(id);
190
+ const filename = f.filename;
191
+ const mime = f.mime_type;
192
+ const thumb = f.thumbnail_url;
193
+ const size = f.size;
194
+ const created_at = f.created_at;
195
+ out.push({
196
+ id,
197
+ filename: typeof filename === "string" ? filename : "",
198
+ mime_type: typeof mime === "string" ? mime : "",
199
+ url,
200
+ thumbnail_url: typeof thumb === "string" ? thumb : undefined,
201
+ size: typeof size === "number" ? size : undefined,
202
+ created_at: typeof created_at === "string" ? created_at : undefined,
203
+ });
204
+ }
191
205
  }
192
206
  return out;
193
207
  }
@@ -442,7 +442,7 @@ the server's answer is the only check — the rule the filter keys follow too.
442
442
  | `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty), a LOOKUP of a link field included |
443
443
  | `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
444
444
  | `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email?, image?, groups? }[]` (`[]` when empty) |
445
- | `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
445
+ | `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty), a LOOKUP of a files field included |
446
446
  | `readLocked(rowObj)` | the whole **row** → `boolean` | the record's lock state from `__source_locked` |
447
447
  | `readCreatedAt(rowObj)` | the whole **row** → `Date \| null` | when the record was created, from `__created_at` |
448
448
  | `readUpdatedAt(rowObj)` | the whole **row** → `Date \| null` | when the record was last updated, from `__updated_at` |
@@ -498,8 +498,11 @@ anonymous public-app visitors; re-querying refreshes the TTL naturally. Entries
498
498
  presign (no `url`) are skipped, so you never render an unservable file. `size` (bytes) and
499
499
  `created_at` (ISO upload timestamp) are resolved at serving time; `size` is absent for older files
500
500
  not yet backfilled — render it only when present, and surface both as dedicated sortable columns
501
- over the raw values (a formatted "8.4 MB" string sorts wrong). Previewing and uploading files:
502
- [./files.md](./files.md).
501
+ over the raw values (a formatted "8.4 MB" string sorts wrong). **A LOOKUP of a files field arrives
502
+ one level deeper**, exactly as a looked-up link does — one entry per linked record, each entry that
503
+ record's whole files cell — and `readFiles` opens it, in link order and with a file two linked rows
504
+ both carry named once, so a looked-up picture needs no unwrapping of its own. Previewing and
505
+ uploading files: [./files.md](./files.md).
503
506
 
504
507
  **Warning — the presign ceiling:** signing file URLs is per-entry server work, so a response is
505
508
  capped at **2,000 file entries** (server default). A query over the cap **fails** with an error
package/docs/files.md CHANGED
@@ -241,6 +241,13 @@ Decode with `readFiles(cell)` → `AppFile[]` (`dist/src/row.d.ts`):
241
241
  `readFiles` skips entries the server didn't presign (no `url`), so you never render an unservable
242
242
  file. It is pure and never throws.
243
243
 
244
+ - **A LOOKUP of a files field is one list per linked row.** The cell holds one entry per linked
245
+ record and each entry is that record's whole files cell, so the value arrives one level deeper
246
+ than a stored column's — exactly as a looked-up link does. `readFiles` opens that level itself,
247
+ in link order, naming a file two linked rows both carry once, so a picture read through a link
248
+ needs no unwrapping at the call site. Read entry-by-entry it decodes to nothing, which is a mark
249
+ falling back to its glyph while the bytes are plainly there.
250
+
244
251
  - **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
245
252
  the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
246
253
  falls back to `url` on image error — a hand-rolled `<img src={thumbnail_url}>` must do the same.
@@ -265,7 +265,7 @@ client-side up front, and re-checked server-side.
265
265
  - **The author is always the real signed-in member** — correct attribution, enforced server-side.
266
266
  **Warning:** under "View as", comments still author as the real member (the admin), not the
267
267
  view-as target — unlike `useViewer` and `is_current_member` scoping, which follow the target.
268
- - **Edit and delete are author-only**, checked server-side against the viewer. `CommentThread`'s
268
+ - **Edit and delete are author-only**, checked server-side against the viewer. `EntryList`'s
269
269
  `currentMemberId` prop drives the matching affordance client-side.
270
270
 
271
271
  ### Reading & writing
@@ -286,13 +286,13 @@ renders the panel. State: `{ comments, loading, error, available, createComment,
286
286
  updateComment, deleteComment, refetch }`.
287
287
 
288
288
  - `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
289
- `CommentThread`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
289
+ `EntryList`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
290
290
  table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
291
291
  (`AppCommentFile`) carry `id` / `filename` / `mime_type` — a file's identity is its `id`, and
292
292
  the server re-reads every attachment from storage by that id, so nothing else you hold about a
293
293
  file can affect what is stored. The `url` / `thumbnail_url` / `preview_url` fields exist on the type
294
294
  but the server does not populate them today — render attachments by name and type (what
295
- `CommentThread`'s default file row does), never by counting on a fetchable URL.
295
+ `EntryList`'s default file row does), never by counting on a fetchable URL.
296
296
  - `createComment({ content, file_ids? })` — posts as the viewing member. `file_ids` come from
297
297
  `useFileUpload` / `useAttachments` (see [files](./files.md)). A comment must have content or at
298
298
  least one file (empty input is a client no-op; the server enforces the same rule). Content max
@@ -308,7 +308,7 @@ updateComment, deleteComment, refetch }`.
308
308
  app by someone the record references nowhere. So a thread that crosses a role boundary is legible without the app declaring member
309
309
  access it does not otherwise need, and a per-viewer app never widens its reach for a display
310
310
  string. `name` is `null` when the id no longer resolves in the org (a removed member) — render a
311
- fallback (`CommentThread` has an `unknownMember` label for exactly this). The one comment with no
311
+ fallback (`EntryList` has an `unknownMember` label for exactly this). The one comment with no
312
312
  `author` is the optimistic row `createComment` renders locally: the SDK knows the viewer's id and
313
313
  not their name, and it will not invent one. The server's row replaces it when the refetch lands.
314
314
  - **Freshness:** SWR-cached, revalidates on focus/reconnect, so another viewer's comment appears on
@@ -336,7 +336,7 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
336
336
  | A select value (stored or picker option) | `Status` | 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. |
337
337
  | A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
338
338
  | A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
339
- | A comment thread | `CommentThread` + `CommentComposer` | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
339
+ | A comment thread | `EntryList` + `CommentComposer` | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
340
340
 
341
341
  The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
342
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.97.0",
3
+ "version": "0.98.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {