@lotics/app-sdk 0.123.10 → 0.124.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.
@@ -261,7 +261,7 @@ host did not say (a visitor from outside the organization, an older host). Both
261
261
  context, read once as the app opens. Display only: the workflow body re-checks who may act
262
262
  (`current_member_stands_in_for(...)`).
263
263
 
264
- `openableApps` lists the workspace's apps the viewer can open, each by its `id` and its `name` — what a
264
+ `openableApps` lists the workspace's apps the viewer can open, each by its `id` and its `name`, and `table_id`, the table its register lists, where the host says — what a
265
265
  way through to a sibling app (`openApp`, `peekApp`) is offered by. `null` where the host did not say (standalone, a
266
266
  render, an older host, or a page that had not listed its apps when the app opened). Display only: the
267
267
  app opened checks the viewer itself.
@@ -301,7 +301,7 @@ Opt in on the app — a JSON app states it in its model; a custom-code app sets
301
301
  version:
302
302
 
303
303
  ```jsonc
304
- // update_app
304
+ // set_app
305
305
  { "app_id": "app_…", "capabilities": { "comments": true } }
306
306
  ```
307
307
 
@@ -378,65 +378,17 @@ table's id: read it off a row of the query that renders the badges (`rows[0]?.__
378
378
  rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
379
379
  row is on screen — no request is sent and `counts` is `{}`.
380
380
 
381
- ## Record activity: `useRecordActivity`, `undoRecordChange`
381
+ ## Outside links: `useOutsideLinks`
382
382
 
383
- Who changed which of a record's fields, and who commented on it — one entry per save
384
- (`dist/activity.d.ts`). For a JSON app: `entity` is the alias the app's spec names the record's
385
- entity under.
386
-
387
- ```tsx
388
- const { entries, next, loading, error, available, refetch } = useRecordActivity("job", recordId);
389
- // The page past it, mounted where the reader asks for more:
390
- const older = useRecordActivity("job", recordId, { after: next });
391
- // One change entry put back, as the viewer's own save:
392
- const { reverted, kept } = await undoRecordChange("job", recordId, entry.id);
393
- ```
394
-
395
- - **Authority: the app's own read of the record.** The server reaches the record through the
396
- entity's read of one row — the read the record screen draws it with — so a row that read does not
397
- reach (the reader's scope, another workspace's) answers not found. Only the fields that read draws
398
- appear: a field the app does not read never does, and an edit of one neither shows nor splits an
399
- entry. A field taken out of the app while its table keeps it stays hidden; a field gone from its
400
- table appears where the version serving when it was written read it, marked `removed`. Comments
401
- appear only where the app declares `comments`.
402
- - **Members of the app's organization only** — the trail names its people. For a public visitor,
403
- a member of another organization, and standalone, `available` is `false` and nothing is asked.
404
- - **One entry per save.** Edits by one actor on one record less than ten minutes apart read as one
405
- entry: each field from its value before the first edit to its value after the last, a field edited
406
- back to where it began dropped (and an entry left with nothing). A comment, another actor or a
407
- longer pause starts another entry, and the save that made the record is an entry alone (`created`):
408
- its maker's edits after it, however soon, are entries of their own. `at` is the entry's first
409
- write, `until` its last.
410
- - **An entry** (`ActivityEntry`) is `kind: "change"` with `changes`, or `kind: "comment"` with
411
- `comment: { id, body, mentions }`. A change entry also says `undoable` — the viewer may put it
412
- back — `undone: { by, at }` once someone has, and `undo: true` where it is itself the save that
413
- put an earlier one back. `actor`: `{ kind, id, name, image? }` — `member` (a person),
414
- `agent` (an AI acting for a member: the chat, an app's agent, an MCP client — named by that
415
- member), `workflow` (a workflow on its own, named by the workflow) or `import` (a script over the
416
- API or the CLI, named by the member whose key ran it). `name` is empty where nothing resolves it.
417
- - **A change** (`ActivityChange`): `{ field_id, label, removed?, before, after }`. `field_id` is a
418
- spec field's `id`; `label` the app's label today, else — a removed field — its label when written.
419
- Values are drawn as the record's read draws them, so the cell readers apply: a select
420
- `[{ key, label }]` (`readSelect`; each option's label as it was when written), a member field
421
- `[{ id, name, image? }]`, a link `[{ id, display }]` (`readLinks`), files presigned (`readFiles`),
422
- `null` for empty.
423
- - **`undoRecordChange(entity, record_id, entry_id)`** puts one change entry back: each field it
424
- changed that the app reads returns to what it held before the entry, where the record still holds
425
- what the entry wrote. It answers `{ reverted, kept }`: the fields put back (`{ field_id, label }`),
426
- and those left as they stand with a `reason` — `changed_since` (someone changed it since) or
427
- `refused` (the table refused the write: a lock, a check). A field already holding what it held
428
- before is in neither. The write is the viewer's own save: they hold `write_data` on the table as
429
- the call arrives, on a row their own row rule covers, and the record's lock, the table's checks
430
- and its write rules answer as for any save of theirs. `undoable` is `false`, and the call
431
- refused, for the entry that made the record, an entry that is an undo, one already put back, a
432
- viewer who may not write the record, and a JSON app that saves no record of the entity (an undo is
433
- a save); a record in the trash is refused until it is restored.
434
- The undo is an entry of its own; the answer arrives once the record's reads are read anew.
435
- - **Pages**: newest first, about thirty entries each; `next` is where the trail goes on —
436
- `undefined` at its end. A page may hold fewer entries and still say `next` where the trail it read
437
- held little the reader may see.
438
- - **Freshness:** read again when a push or this app's own write names the entity's reads, when a
439
- comment is written or deleted through `useComments`, on focus, and on `refetch()`.
383
+ `useOutsideLinks(recordId)` (`dist/outside_link.d.ts`) → `{ links, loading, error, available, create, revoke }` — a
384
+ member's links to one record, for the record screen of an app whose model states an `Outside` (the person with no seat
385
+ who opens a link; see the model reference). `available` is true for a signed-in member of the app's organization; for
386
+ anyone else nothing is asked and `links` stays empty. Pass `null` where the record draws no Outside: nothing is
387
+ asked either, and `create` and `revoke` throw `OutsideLinkRecordError`. `links` holds the record's title and, per link, who it is for, when
388
+ it closes, whether it was opened and why a closed one closed; the secret is never in it. `create(outside)` and
389
+ `revoke(link)` ask the **host**, which draws its own dialog (who the link is for and for how long; a confirmation),
390
+ makes or withdraws the link and shows the address itself, so the frame never sees or sends a secret. `create` resolves
391
+ to the link made, or `null` when the member backed out; `revoke` to whether it did. Both read the list again.
440
392
 
441
393
  ## Where each hook works
442
394
 
@@ -449,5 +401,3 @@ const { reverted, kept } = await undoRecordChange("job", recordId, entry.id);
449
401
  | `useViewer` | member id (view-as target) | `null` |
450
402
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
451
403
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
452
- | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false`, nothing asked |
453
- | `undoRecordChange` | ✓ a member holding the table's write, on a row they may write | ✗ rejects |
package/docs/mutations.md CHANGED
@@ -441,7 +441,8 @@ Row-level query results carry a `__source_locked` addressing column automaticall
441
441
  project/filter/sort/join but not grouping, where rows stop being records).
442
442
  `readLocked(row)` — pass the **whole row**, not a cell — returns `true` for a locked record,
443
443
  `false` when the flag is absent (`dist/row.d.ts`). Use it to render a locked state and to
444
- switch the save path.
444
+ switch the save path. `readLockedBy(row)` names who can change it — its table's approvers, else
445
+ the organization's admins — for a member of the app's organization; `[]` for anyone else.
445
446
 
446
447
  ### What a write against a locked record does
447
448
 
@@ -459,8 +460,8 @@ return({ status: "success", message: "Updated." });
459
460
  ```
460
461
 
461
462
  `update_records` has no bypass flag — a locked record's values change only through the
462
- approval flow below (or an admin explicitly unlocking the record first; `unlock_records` is
463
- admin-gated, so don't build an unlock→edit→relock workflow around the approval flow — the
463
+ approval flow below (or the table's owner or an admin explicitly unlocking the record first; `unlock_records` is
464
+ owner- or admin-gated — in an app's run, judged as the member who pressed, never the app's owner — so don't build an unlock→edit→relock workflow around the approval flow — the
464
465
  lock exists to route changes through review).
465
466
 
466
467
  ### The change-request path: `request_locked_record_change`
package/docs/recipes.md CHANGED
@@ -119,7 +119,7 @@ the wrong half is *plausible and wrong* rather than broken.
119
119
 
120
120
  Two **separate** layers; set both.
121
121
 
122
- **Launcher chrome** — `lotics run update_app '{"app_id":"…","icon":"truck","theme":{"color":"blue"}}'`.
122
+ **Launcher chrome** — `lotics run set_app '{"app_id":"…","body":{"icon":"truck","color":"blue"}}'`.
123
123
  `icon` is any Lucide name (the full library, validated at write time — a wrong name is rejected, not
124
124
  silently boxed). Don't guess the kebab spelling:
125
125
 
@@ -127,9 +127,9 @@ silently boxed). Don't guess the kebab spelling:
127
127
  lotics run search_app_icons '{"query":"shipping"}' → ["truck","ship","package",…]
128
128
  ```
129
129
 
130
- `theme.color` is a named palette colour: `red orange amber yellow lime green emerald teal cyan sky
131
- blue indigo violet purple fuchsia pink rose slate gray zinc neutral stone`. `null` clears either.
132
- `update_app` sets both.
130
+ `color` is a named palette colour: `red orange amber yellow lime green emerald teal cyan sky
131
+ blue indigo violet purple fuchsia pink rose slate gray zinc neutral stone`. `null` in `body` clears
132
+ either. `set_app` sets both.
133
133
 
134
134
  **In-app palette** — the app's own `src/theme.ts`, exact hex. This is what the app *renders* with;
135
135
  the launcher colour does not feed it. Pick the named colour closest to your brand hex.
package/docs/runtime.md CHANGED
@@ -219,7 +219,6 @@ surfaces:
219
219
  |---|---|---|
220
220
  | `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes |
221
221
  | `comments.*` | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
222
- | `activity.list`, `activity.undo` | yes, for a member of the app's organization; `activity.undo` for one who may write the record. A host that does not carry the op answers `"Unknown RPC op: activity.undo"` | rejects — `"A record's activity is available only in embedded apps — a signed-in member is required."` |
223
222
  | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes |
224
223
  | `agentRun.get`, `agentRun.cancel` | yes | yes |
225
224
  | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | rejects — a standalone caller is anonymous, and a history is its member's (`"App agent runs require an authenticated member …"`) |
@@ -275,6 +274,11 @@ semantics are documented:
275
274
  | `urlState.get` / `urlState.set` | — / `{ params }` | `UrlParams` / `void` | [navigation & state](./navigation_and_state.md) |
276
275
  | `comments.list/create/update/delete/counts` | comment ops | comment payloads | [members & options](./members_and_options.md) |
277
276
  | `recording.start` / `recording.stop` | `{ alias, inputs }` / `{ alias }` | `{ started }` / `void` | [mutations](./mutations.md#recording-into-a-workflow-userecording) |
277
+ | `outsideLink.list/create/revoke` | `{ record_id }` / `{ record_id, outside }` / `{ record_id, link_id }` | links / link or `null` / `{ id }` or `null` | [members & options](./members_and_options.md#outside-links-useoutsidelinks) |
278
+
279
+ `installRpcTransport(transport)` answers every op itself, for a page that is never an app's (the page a person with no
280
+ seat opens from a link to one record); the last install wins and a page installs its own before it mounts. An app never
281
+ calls it.
278
282
 
279
283
  The **streaming** agent-run op is *not* reachable through `rpc()` — its
280
284
  response is a chunk stream, not a single value; it's internal to `useAgentRun`.
package/docs/security.md CHANGED
@@ -10,7 +10,6 @@ Every data operation an app performs — queries, workflows, agent runs — exec
10
10
  | Workflows (`useWorkflow`) | App owner | Yes — `runtime.triggered_by_member_id` in the workflow body (`null` for anonymous) |
11
11
  | Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
12
12
  | Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
13
- | Record activity (`useRecordActivity`) | The app's own read of the record — the entity's read of one row, and only the fields it draws | Always — the app's organization's members only |
14
13
 
15
14
  Consequences of owner authority:
16
15
 
@@ -102,8 +101,6 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
102
101
  | Query/workflow file outputs | Yes — file cells and workflow-produced files return direct presigned URLs (24-hour TTL) that anonymous viewers can fetch; see [files](./files.md) |
103
102
  | Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
104
103
  | Comments (`useComments`) | **No** — members-only |
105
- | Record activity (`useRecordActivity`) | **No** — the app's organization's members only |
106
- | Undo of a record's change (`undoRecordChange`) | **No** — a member of the app's organization holding `write_data` on the record's table, on a row their own row rule covers, in an app that saves records of that entity |
107
104
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
108
105
 
109
106
  **Warning — `is_current_member` on a public app resolves to the app owner.** An anonymous request has no viewer to bind, so both `is_current_member` and `current_member in_any_group` fall back to the owner's identity and the owner's groups. A "my items" query shows every anonymous visitor the *owner's* items, and a manager-only read template gated on a group the owner belongs to **passes for every anonymous visitor**. Never rely on viewer-bound predicates in a public app.
package/docs/workflows.md CHANGED
@@ -34,8 +34,7 @@ if (order.data["fld_status"] == "opt_open") { … }
34
34
  A body is the app's, stored on the server and bound to an alias: `set_app_workflow` takes the
35
35
  alias, the body's `source`, and the alias's `inputs` (leave `outputs` off to let the body's
36
36
  `return({ data })` derive it). The server verifies the body and the write lands live, as a new
37
- version of the app; `get_app_workflow` reads it back, and `rollback_app` returns the app to a
38
- version before it. Input declaration vocabulary and validation:
37
+ version of the app; `get_app_workflow` reads it back, and an earlier body is restored by writing it again. Input declaration vocabulary and validation:
39
38
  [mutations](./mutations.md#declaring-workflow-inputs).
40
39
 
41
40
  An app workflow body carries **no trigger declaration** — the binding supplies the trigger
@@ -138,7 +137,7 @@ The body is a sequence of statements, each of which maps 1:1 onto a stored step.
138
137
  | Pause | `await wait({ duration_in_minutes: 5 });` |
139
138
  | Pause for an event | `await wait_for_event({ event_type: "payment" \| "webhook", event_ref, timeout_in_minutes? });` |
140
139
  | Pause for a decision | `const a = await wait_for_approval({ approvers, prompt, timeout_in_minutes? });` |
141
- | LLM reasoning | `const x = await agent({ instructions, input, tools, model?, output });` |
140
+ | LLM reasoning | `const x = await agent({ instructions, input, model?, output });` |
142
141
  | Run a declared agent | `const x = await app_agent({ alias, input, wait? });` |
143
142
  | Guard | `validate({ checks: [{ fail_when, field_key?, message }, …] });` |
144
143
  | End the run | `return({ status, message, field_errors?, data? });` |
@@ -283,7 +282,6 @@ const order = await get_record({ table_id: "tbl_orders", record_id: i.order_id }
283
282
  const summary = await agent({
284
283
  instructions: "Summarize the order in one sentence for an internal digest.",
285
284
  input: { code: order.data["fld_order_code"], total: order.data["fld_total"] },
286
- tools: [],
287
285
  output: { mode: "text" },
288
286
  });
289
287
  ```
@@ -293,13 +291,14 @@ name" when broken:
293
291
 
294
292
  - **Only `input` sees the workflow.** It must be an **object literal**; its field values are
295
293
  ordinary expressions and are the *only* workflow data the agent receives. `instructions` is a
296
- static string literal and `tools` a literal array of tool names — neither may read a binding,
297
- so do not interpolate a record into `instructions`; move it into `input`.
294
+ static string literal and may not read a binding, so do not interpolate a record into
295
+ `instructions`; move it into `input`. An agent step calls no tool and states no `tools`: the
296
+ workflow's own steps write, send and notify with what it returns.
298
297
  - **Only the bound name comes back.** In `{ mode: "text" }` the result *is* the string — use it
299
298
  directly. In `{ mode: "object", schema }` the `schema` is itself a top-level object declaration
300
299
  (`{ type: "object", fields: { total: { type: "number" }, … } }`, the same vocabulary as an
301
300
  alias's `outputs`); read the declared fields as `x.field`, and a name not in `schema` does not
302
- exist. The agent's own tool calls and reasoning are not readable.
301
+ exist. The agent's reasoning is not readable.
303
302
 
304
303
  `model` is an optional pin — omit it to follow the platform default. The step is atomic (no wait
305
304
  or approval inside it).
@@ -494,12 +493,12 @@ full menu by category, so a miss is one informed retry.
494
493
 
495
494
  | Category | Helpers |
496
495
  |---|---|
497
- | **Type / null** | `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`, `isObject`, `coalesce`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson` |
496
+ | **Type / null** | `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`, `isObject`, `coalesce`, `toNumber`, `toFigure`, `toString`, `typeOf`, `parseJson`, `toJson` |
498
497
  | **Array** | `size`, `first`, `requireFirst`, `last`, `nth`, `at`, `slice`, `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`, `uniqueBy`, `compact`, `flatten`, `reverse`, `concat`, `difference`, `differenceBy`, `intersection`, `intersectionBy`, `list`, `range`, `reduce` |
499
498
  | **Number** | `sum`, `sumBy`, `mean`, `meanBy`, `min`, `max`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage` |
500
- | **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber(value, decimals)` (fixed-decimal, ungrouped), `formatDecimal(value, decimals, locale)` (grouped for a reader — `formatDecimal(151000, 0, "vi-VN")` → `151.000`), `numberToWords(x, lang?)` (`"vi"` or `"en"`) |
499
+ | **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `uuidFrom(text)` (the same UUID for the same text), `formatNumber(value, decimals)` (fixed-decimal, ungrouped), `formatDecimal(value, decimals, locale)` (grouped for a reader — `formatDecimal(151000, 0, "vi-VN")` → `151.000`), `numberToWords(x, lang?)` (`"vi"` or `"en"`) |
501
500
  | **Object** | `keys`, `values`, `entries`, `get`, `pick`, `omit`, `merge`, `nonNullKeys` |
502
- | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
501
+ | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange`, `meetsBounds` |
503
502
  | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])`, `current_member_stands_in_for([memberId])`, `current_member_stands_in_for_any_group(["grp_…"])` |
504
503
 
505
504
  **Emptiness on a record cell.** `isNull` is the strict check — true only for `null`/`undefined`.
@@ -752,7 +751,7 @@ existed only to feed it, which is usually where the round trips were hiding.
752
751
 
753
752
  ### Measuring, if you do
754
753
 
755
- `query_workflow_executions` records `started_at` / `completed_at` per run, the server's own
754
+ `query_workflow_executions` and `get_workflow_execution` give `started_at` / `completed_at` per run, the server's own
756
755
  duration. **Run-to-run variance is large** (a second, minutes apart), so compare medians of several
757
756
  interleaved runs, never one before/after pair.
758
757
 
@@ -849,41 +848,15 @@ fix-stating messages before you see them.
849
848
 
850
849
  `check` and `set` prove parse, types, name resolution, structure, and lint — **none of them
851
850
  evaluates an expression**. A body that saves clean can still take the wrong branch, hand a tool a filter it
852
- rejects, or read a path that is null on real data. The rehearsal for that is `dry_run_workflow`
853
- (`lotics run dry_run_workflow '<json>'`): pass the `source`, `trigger_type: "app_workflow"`,
854
- `table_id: null`, the input **values** as `trigger_payload`, and the declared schema as
855
- `app_workflow_inputs`. It walks the real step tree with the production expression evaluator and
856
- hands back `planned_calls` (every tool call in order, with its fully-resolved input),
857
- `return_value`, `validation_failures`, `evaluation_errors`, and `tool_input_errors` — inputs the
858
- target tool would reject, which can only surface once the values are computed. Loops run for real,
859
- every iteration, so a fold or a per-row fan-out is rehearsed at its true size. **Write tools are
860
- recorded, never dispatched**, and nothing is persisted.
861
-
862
- **Add `live_reads: true` whenever the body READS.** By default the read-only tools
863
- (`query_records`, `get_record`, `aggregate_records`) return stubs — an empty result set, a blank
864
- record — so any branch gated on stored data takes the empty path. With `live_reads: true` those
865
- three dispatch against the real workspace under YOUR authority while writes stay recorded-only. (A
866
- table-triggered body gets this from `record_id` + `table_id`; an app workflow has no trigger
867
- record, so it asks directly.) Do this before the first live run of anything that writes.
868
-
869
- **`return_value` is what the CALLER receives**, not a summary of it: `{ status, message }` always,
870
- plus `data` when the body returns one and `field_errors` when it returns those — the same map a
871
- form reads. A `validate` step that fails produces the same shape the live run does: every failing
872
- check's message joined with `"; "`, and one `field_errors` entry per failing check that names a
873
- `field_key`.
874
-
875
- **A rehearsal can stop early, and it says so in `evaluation_errors`.** Four bounds apply — 1 000
876
- iterations of a `while` / `do_while` / `c_for`, 10 000 steps, 50 live reads, and 20 seconds — and
877
- tripping any of them appends an entry naming the bound and ending "the plan below is incomplete",
878
- then stops. It also stops, with an entry saying why, where the live run would fail: at a
879
- `validate` check that throws, an evaluation past the run's compute or memory bounds (*Traps*), or a
880
- return too long to write.
881
- So read `evaluation_errors` before reading `planned_calls`: an entry there may be a
882
- truncated plan rather than a bug in your body. A `foreach` is deliberately NOT capped at 1 000 —
883
- its length is known before the first iteration, so the rehearsal walks every item and instead
884
- refuses, up front, exactly the lists a live run refuses (over 10 000 items). The 50-live-read bound
885
- is the one `live_reads: true` makes reachable: a loop body that reads issues one real query per
886
- iteration.
851
+ rejects, or read a path that is null on real data. The rehearsal for that is `set_app_workflow` with
852
+ `verify_only: true` and a `record_id` (the row an Act would run on) or `preview_inputs` (the values of a
853
+ workflow that takes inputs and no row): the draft is walked by the engine an Act's preview uses, under the
854
+ app's authority, with the production expression evaluator. Reads are real; writes and service sends are
855
+ said, not made, and a document a step renders is made as a paper no listing shows. It answers `preview`
856
+ (the calls the run would make, each with its fully-resolved input, and the reads it made) and `returned`
857
+ (`{ status, message }` always, plus `data` and `field_errors` when the body returns them — what the caller
858
+ receives). A draft it cannot rehearse — a step that reads what a call before it answers, a wait, a bound the
859
+ walk reaches — answers `preview.available: false` and the reason. Nothing is written.
887
860
 
888
861
  ## A workflow that receives a recording
889
862
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.123.10",
3
+ "version": "0.124.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through — typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,40 +0,0 @@
1
- import type { ActivityEntry, ActivityUndoResult } from "./shared_types.js";
2
- export interface RecordActivityState {
3
- /** Newest first. */
4
- entries: ActivityEntry[];
5
- /** Where the trail goes on — pass it as `after` to read the page past this one; `undefined` at its end. */
6
- next: string | undefined;
7
- loading: boolean;
8
- error: string | null;
9
- /** A signed-in member of the app's organization; for anyone else nothing is read and `entries` stays empty. */
10
- available: boolean;
11
- refetch: () => void;
12
- }
13
- export interface RecordActivityOptions {
14
- /** A page's `next`: read the page past it. Absent, the newest page. */
15
- after?: string;
16
- }
17
- /**
18
- * One page of the record's activity. `entity` is the alias the app's spec names the record's entity under; `record_id`
19
- * a real id. Read again when a push or this app's own write names the entity's reads, and when a comment is written.
20
- *
21
- * ```tsx
22
- * const { entries, next } = useRecordActivity("job", recordId);
23
- * // The page past it, mounted where the reader asks for more:
24
- * const older = useRecordActivity("job", recordId, { after: next });
25
- * ```
26
- */
27
- export declare function useRecordActivity(entity: string, record_id: string, options?: RecordActivityOptions): RecordActivityState;
28
- /**
29
- * Put one change entry of the record's activity back, as the viewer's own save: each field the entry changed returns
30
- * to what it held before it where the record still holds what the entry wrote. The answer names the fields put back
31
- * (`reverted`) and those left as they stand (`kept`), each with its `reason` — `changed_since`, or `refused` where the
32
- * table refused the write (a lock, a check) — and arrives once the record's reads are read anew. Refused for an entry
33
- * whose `undoable` is false: the record's making, an undo, an entry already put back, and a viewer who may not write
34
- * the record.
35
- *
36
- * ```tsx
37
- * const { reverted, kept } = await undoRecordChange("job", recordId, entry.id);
38
- * ```
39
- */
40
- export declare function undoRecordChange(entity: string, record_id: string, entry_id: string): Promise<ActivityUndoResult>;