@lotics/app-sdk 0.58.5 → 0.59.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
@@ -16,7 +16,7 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
- | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`. |
19
+ | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, read-after-write ordering (a re-read must not overtake an in-flight write). |
20
20
  | [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 + link 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 `lotics app workflow check` loop. |
21
21
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, 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
22
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
package/docs/mutations.md CHANGED
@@ -319,6 +319,62 @@ flash to a spinner — `loading` stays false during revalidation). `usePaginated
319
319
  (`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
320
320
  in place of an explicit refetch after a write the user is watching for.
321
321
 
322
+ ### A read must not overtake an in-flight write
323
+
324
+ `refetch()` and a direct re-read know nothing about a write that is still running. On a record
325
+ surface with inline (blur-committing) fields, a single gesture starts both: the field's write on
326
+ mousedown, the button's handler on mouseup — so a handler that re-reads the record to build a
327
+ document, mint something, or copy the row can read the row as it was BEFORE the edit. It fails
328
+ silently: the screen shows the new value, the output carries the old one.
329
+
330
+ Serialize them with one barrier per record surface — every write chains on, and the ONE place
331
+ that re-reads stored state awaits it, so no handler can forget:
332
+
333
+ ```tsx
334
+ // Per surface, not module-level: useMemo(createWriteBarrier, []) inside the hook.
335
+ export function createWriteBarrier() {
336
+ let chain: Promise<unknown> = Promise.resolve();
337
+ return {
338
+ // Returns the write UNCHANGED — the caller keeps its own result + error handling;
339
+ // the chain never rejects, it only tracks when writes SETTLE.
340
+ track: <T,>(write: Promise<T>): Promise<T> => {
341
+ chain = chain.then(() => write).catch(() => undefined);
342
+ return write;
343
+ },
344
+ settled: () => chain,
345
+ };
346
+ }
347
+
348
+ const saveField = async (key: string, value: unknown) => {
349
+ const r = await writes.track(saveRecord({ record_id, [key]: value }));
350
+ if (r.status === "error") throw new Error(r.message); // the inline editor keeps the edit
351
+ };
352
+
353
+ /** The ONLY re-read of stored state. Every handler goes through it — never the cached row. */
354
+ const reload = async () => {
355
+ await writes.settled();
356
+ const res = await rpc<{ rows?: Rec[] }>("query", { alias: "record", params: { id: record_id } });
357
+ return res.rows?.[0] ?? null;
358
+ };
359
+
360
+ const printDoc = async () => {
361
+ const fresh = (await reload()) ?? current; // reflects the edit the press just committed
362
+ await issueDoc(buildDoc(fresh));
363
+ };
364
+ ```
365
+
366
+ A handler that reads the row held in state instead has the same defect with no in-flight write
367
+ needed — any edit since the row was loaded is missing. Build documents and mints from the
368
+ re-read, not from the cached row.
369
+
370
+ The same trap one level up: **an action takes an ID, never a captured object.**
371
+ `onPress={() => issueInvoice(invoice)}` closes over the invoice as it was when the press
372
+ happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
373
+ pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
374
+ then re-read or `find` where it is consumed). `@lotics/ui` gates the press itself
375
+ (`docs/data_entry.md` § Inline edit), which fixes the ordering; the captured value is the app's
376
+ to get right.
377
+
322
378
  ## Diff before update — send only what changed
323
379
 
324
380
  An edit form snapshots the record's values when it loads, and on save sends **only the fields
package/docs/queries.md CHANGED
@@ -583,7 +583,7 @@ the table.
583
583
 
584
584
  ## 8. Shaping: group, buckets, window, fan-out
585
585
 
586
- ### The 20 aggregate operations
586
+ ### The 21 aggregate operations
587
587
 
588
588
  `group` and `window` share one operation vocabulary. `count` is `COUNT(*)` (no `input_column`);
589
589
  everything else requires an `input_column` whose type must be compatible — checked at deploy:
@@ -599,10 +599,38 @@ everything else requires an `input_column` whose type must be compatible — che
599
599
  | `percent_filled`, `percent_empty` | any except boolean | number | **fraction 0–1**, not 0–100; NULL for an empty group |
600
600
  | `unique`, `percent_unique` | text, number, date/datetime, select, select_member, select_record_link, files, json¹ | number | distinct **present** values; array cells compare as whole arrays |
601
601
  | `checked`, `unchecked`, `percent_checked`, `percent_unchecked` | boolean | number | `unchecked` counts false **or** empty |
602
+ | `string_agg` | text, select | **text** | the distinct present values joined — the only operation returning values rather than a count. `group` only |
602
603
 
603
604
  ¹ opaque `json` columns support only the presence-counting six (`empty`/`filled`/`unique` and
604
605
  their `percent_*` forms).
605
606
 
607
+ **`string_agg` — a summary column, not a dataset.** Every other operation counts or reduces to a
608
+ number; this one joins the values, so a child set answers "which ones?" in the parent row (the
609
+ sizes on a shipment, the tags on a ticket) without a second query.
610
+
611
+ ```jsonc
612
+ { "output": "sizes", "type": "text", "operation": "string_agg", "input_column": "size",
613
+ "distinct": true, "separator": ", ", "max_values": 20 }
614
+ ```
615
+
616
+ - **`type` must be `text`** — declaring anything else is rejected at deploy.
617
+ - **`distinct`** defaults to **true**: three containers sized 40HC/40HC/20DC give `20DC, 40HC`.
618
+ Pass `false` to keep every occurrence. Values are always sorted, so the column doesn't
619
+ reshuffle between reads.
620
+ - **`max_values`** (default 20, max 100) caps the emitted values so an unbounded child set can't
621
+ produce a giant cell. It is a silent cap — pair it with a `unique` aggregate over the same
622
+ column to render an honest `+N more`.
623
+ - **`separator`** defaults to `", "` (max 8 chars).
624
+ - Empty values are dropped by the same emptiness contract below, so a partly-blank column
625
+ doesn't emit empty slots. A group with nothing present yields NULL.
626
+ - **`group` only** — it is not in the OVER-legal set, because deduplication and window frames
627
+ are mutually exclusive in SQL. Setting `distinct` / `separator` / `max_values` on any other
628
+ operation is rejected rather than ignored.
629
+
630
+ It is deliberately **not** a rollup field type: a rollup persists its value into record data and
631
+ rewrites it on every child change, and an unbounded concatenation does not belong in a stored
632
+ cell. Query-time only.
633
+
606
634
  **The one emptiness contract.** `filled`/`empty`/`unique`/`percent_*` use the same definition
607
635
  of "present" as the filter layer's `is_empty` and the `isEmpty` source: array-valued cells are
608
636
  empty at NULL / JSON `null` / `[]`; text at NULL or blank (whitespace-only); opaque json at
@@ -643,8 +671,8 @@ A `window` node carries two independent column lists — **`aggregates`** (frame
643
671
  **`aggregates` — the OVER-legal aggregate subset.** Only operations that compile to a single
644
672
  legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
645
673
  `filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
646
- ordered-set aggregate; `unique`/`percent_unique` need DISTINCT; `range`/`empty`/`date_range`/
647
- `percent_*` compose multiple calls) — rejected at deploy. The optional `frame` applies **only**
674
+ ordered-set aggregate; `unique`/`percent_unique`/`string_agg` need DISTINCT;
675
+ `range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at deploy. The optional `frame` applies **only**
648
676
  to these; a `frame` on a window with no `aggregates` is rejected as dead config.
649
677
 
650
678
  **`functions` — ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.58.5",
3
+ "version": "0.59.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {