@lotics/app-sdk 0.120.3 → 0.121.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.
package/AGENTS.md CHANGED
@@ -20,7 +20,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
21
21
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity` and a photo's `place`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
23
- | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`, a record's activity (`useRecordActivity`). |
23
+ | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`, a record's activity and the undo of one change (`useRecordActivity`, `undoRecordChange`). |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model, the screens and `drawers` it reports to the host), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
@@ -1,4 +1,4 @@
1
- import type { ActivityEntry } from "./shared_types.js";
1
+ import type { ActivityEntry, ActivityUndoResult } from "./shared_types.js";
2
2
  export interface RecordActivityState {
3
3
  /** Newest first. */
4
4
  entries: ActivityEntry[];
@@ -6,7 +6,7 @@ export interface RecordActivityState {
6
6
  next: string | undefined;
7
7
  loading: boolean;
8
8
  error: string | null;
9
- /** A signed-in member; standalone nothing is read and `entries` stays empty. */
9
+ /** A signed-in member of the app's organization; for anyone else nothing is read and `entries` stays empty. */
10
10
  available: boolean;
11
11
  refetch: () => void;
12
12
  }
@@ -25,3 +25,16 @@ export interface RecordActivityOptions {
25
25
  * ```
26
26
  */
27
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>;
@@ -1656,6 +1656,7 @@ function rpcStandalone(op, payload) {
1656
1656
  case "comments.counts":
1657
1657
  return rejectCommentsStandalone();
1658
1658
  case "activity.list":
1659
+ case "activity.undo":
1659
1660
  return Promise.reject(new Error("A record's activity is available only in embedded apps \u2014 a signed-in member is required."));
1660
1661
  case "recording.start":
1661
1662
  case "recording.stop":
package/dist/index.d.ts CHANGED
@@ -11,9 +11,9 @@ export type { ExportedFile } from "./shared_types.js";
11
11
  export type { AttachedFile, AttachmentPiles, AttachmentPilesOptions, AttachmentsOptions, AttachmentsState } from "./attachments.js";
12
12
  export { useComments, useCommentCounts } from "./comments.js";
13
13
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
14
- export { useRecordActivity } from "./activity.js";
14
+ export { undoRecordChange, useRecordActivity } from "./activity.js";
15
15
  export type { RecordActivityOptions, RecordActivityState } from "./activity.js";
16
- export type { ActivityActor, ActivityActorKind, ActivityChange, ActivityComment, ActivityEntry, ActivityValue, RecordActivityPage, } from "./shared_types.js";
16
+ export type { ActivityActor, ActivityActorKind, ActivityChange, ActivityComment, ActivityEntry, ActivityUndoResult, ActivityValue, RecordActivityPage, UndoKeptReason, } from "./shared_types.js";
17
17
  export { useAppContext, useViewer, useWorkspaceCurrency, useWorkspaceTimezone } from "./viewer.js";
18
18
  export type { AppAbsence } from "./shared_types.js";
19
19
  export { useRecording, useRecordings } from "./recording.js";
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  sumDecimal,
41
41
  tiersAscend,
42
42
  urlParam
43
- } from "./chunk-ABDSJ25E.js";
43
+ } from "./chunk-MK4DBY6R.js";
44
44
 
45
45
  // src/mount.tsx
46
46
  import { createRoot } from "react-dom/client";
@@ -611,6 +611,14 @@ function readNow(aliases) {
611
611
  function refetchReads(aliases) {
612
612
  for (const slot of readNow(aliases)) void ask(slot, true);
613
613
  }
614
+ async function readsAnswered(aliases) {
615
+ await Promise.all(
616
+ readNow(aliases).map(async (slot) => {
617
+ await ask(slot, true);
618
+ while (slot.inflight !== null) await slot.inflight;
619
+ })
620
+ );
621
+ }
614
622
  function askInOnePaint(read2) {
615
623
  if (read2.length === 0) return;
616
624
  const paint = { waiting: new Set(read2), held: [], drawn: false, cap: setTimeout(() => draw(paint), PAINT_CAP_MS) };
@@ -26395,6 +26403,9 @@ var appQueryDeclarationSchema = zod_default.object({
26395
26403
  ),
26396
26404
  templates: zod_default.record(zod_default.string().regex(APP_ALIAS_REGEX), appExportTemplateSchema).optional().describe(
26397
26405
  "Document templates an export of this query's rows may fill, by the name the export asks for. An export names one of these or none; a template it does not declare is refused."
26406
+ ),
26407
+ screen_only: zod_default.literal(true).optional().describe(
26408
+ "The query feeds one control of the app's screen and answers no question a person asks. It runs like any other; a catalog of what an agent may ask leaves it out."
26398
26409
  )
26399
26410
  });
26400
26411
  var appAgentWritesSchema = zod_default.object({
@@ -26736,6 +26747,15 @@ var TOOL_INPUT_SEMANTICS = {
26736
26747
  // same position `query_records.field_keys` holds.
26737
26748
  return_fields: { kind: "field_key_refs", table_from: "table_id" }
26738
26749
  },
26750
+ import_rows: {
26751
+ table_id: { kind: "table_ref", rows: "change" },
26752
+ match: { kind: "field_key_refs", table_from: "table_id" },
26753
+ // Each row's `values` are keyed by field, a link cell possibly `{ lookup }`; the tool resolves both at execute.
26754
+ rows: { kind: "dynamic" },
26755
+ create: { kind: "scalar" },
26756
+ // `fields` is keyed by field; the tool resolves it at execute.
26757
+ conflicts: { kind: "dynamic" }
26758
+ },
26739
26759
  delete_records: {
26740
26760
  // THE ONE CALL THAT TAKES ROWS. Every column of the row goes with it, so
26741
26761
  // nothing field-keyed can carry the fact and it rides the table position —
@@ -26771,6 +26791,22 @@ var TOOL_INPUT_SEMANTICS = {
26771
26791
  changes: { kind: "dynamic" },
26772
26792
  reason: { kind: "text" }
26773
26793
  },
26794
+ // The record names its own table; an entry's id and a cursor are the trail's.
26795
+ get_record_activity: {
26796
+ record_id: { kind: "record_ref" },
26797
+ after: { kind: "scalar" }
26798
+ },
26799
+ undo_record_change: {
26800
+ record_id: { kind: "record_ref" },
26801
+ entry_id: { kind: "scalar" }
26802
+ },
26803
+ // `table_id` names the table whose rows the undo changes, moves to the trash and brings back; a run's id is the
26804
+ // trail's.
26805
+ undo_record_changes: {
26806
+ table_id: { kind: "table_ref", rows: "remove" },
26807
+ record_ids: { kind: "record_refs" },
26808
+ workflow_execution_id: { kind: "scalar" }
26809
+ },
26774
26810
  // ─── Query surfaces ────────────────────────────────────────────────────
26775
26811
  query_records: {
26776
26812
  table_id: { kind: "table_ref", rows: "none" },
@@ -29032,6 +29068,7 @@ function useAppContext() {
29032
29068
  const { data, error: error51, refetch } = useKey(APP_CONTEXT_KEY, readContext, { focus: false, arrival: false });
29033
29069
  return {
29034
29070
  memberId: data?.member_id ?? null,
29071
+ insider: data !== void 0 && data.member_id !== null && data.group_ids !== void 0,
29035
29072
  groupIds: data?.group_ids ?? null,
29036
29073
  absences: data?.absences ?? null,
29037
29074
  openableApps: data?.openable_apps ?? null,
@@ -32717,8 +32754,7 @@ function oneQueryAlias(entity) {
32717
32754
  var NO_ENTRIES = [];
32718
32755
  function useRecordActivity(entity, record_id, options = {}) {
32719
32756
  const { after } = options;
32720
- const { memberId, resolved } = useAppContext();
32721
- const available = memberId !== null;
32757
+ const { insider: available, resolved } = useAppContext();
32722
32758
  const page = useKey(
32723
32759
  available ? JSON.stringify(["activity", entity, record_id, after ?? null]) : null,
32724
32760
  () => rpc("activity.list", { entity, record_id, ...after === void 0 ? {} : { after } }),
@@ -32734,6 +32770,12 @@ function useRecordActivity(entity, record_id, options = {}) {
32734
32770
  refetch: page.refetch
32735
32771
  };
32736
32772
  }
32773
+ async function undoRecordChange(entity, record_id, entry_id) {
32774
+ const result = await rpc("activity.undo", { entity, record_id, entry_id });
32775
+ refetchReads([listQueryAlias(entity)]);
32776
+ await readsAnswered([oneQueryAlias(entity)]);
32777
+ return result;
32778
+ }
32737
32779
 
32738
32780
  // src/recording.ts
32739
32781
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
@@ -33077,6 +33119,7 @@ export {
33077
33119
  requestGeofencedLocation,
33078
33120
  row,
33079
33121
  rpc,
33122
+ undoRecordChange,
33080
33123
  urlParam,
33081
33124
  useAgentRun,
33082
33125
  useAgentRuns,
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-ABDSJ25E.js";
5
+ } from "./chunk-MK4DBY6R.js";
6
6
 
7
7
  // src/router.tsx
8
8
  import { useEffect } from "react";
package/dist/rpc.d.ts CHANGED
@@ -9,7 +9,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
9
9
  * app → host: { id, op, payload }
10
10
  * host → app: { id, type: "result", data } | { id, type: "error", message }
11
11
  */
12
- export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "activity.list" | "recording.start" | "recording.stop";
12
+ export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "activity.list" | "activity.undo" | "recording.start" | "recording.stop";
13
13
  export interface AgentRunPayload {
14
14
  alias: string;
15
15
  session_id: string;
@@ -3,7 +3,8 @@
3
3
  * `scripts/build_sdk.mjs`, because that package is private and ships in no
4
4
  * tarball. Written at every build, never edited.
5
5
  */
6
- export type ActivityEntry = { changes: { field_id: string; label: string; before: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; after: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; removed?: true | undefined; }[]; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "change"; created?: true | undefined; } | { comment: { id: string; body: string; mentions: { id: string; name: string; }[]; files?: { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | undefined; }; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "comment"; };
6
+ export type ActivityEntry = { undoable: boolean; changes: { field_id: string; label: string; before: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; after: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; removed?: true | undefined; }[]; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "change"; created?: true | undefined; undo?: true | undefined; undone?: { by: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; } | undefined; } | { comment: { id: string; body: string; mentions: { id: string; name: string; }[]; files?: { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | undefined; }; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "comment"; };
7
+ export type ActivityUndoResult = { reverted: { field_id: string; label: string; }[]; kept: { field_id: string; label: string; reason: "changed_since" | "refused"; }[]; };
7
8
  export type AppAbsence = { member: { id: string; name: string; }; stand_in: { id: string; name: string; }; starts_on: string; ends_on: string; group_ids: string[]; };
8
9
  /** The file an app export made, as a link to download it: no workspace file, so no id, and kept a day. */
9
10
  export type ExportedFile = {
package/dist/store.d.ts CHANGED
@@ -4,6 +4,8 @@ export type Fetcher<T> = (askedAt: number, current: T | undefined) => Promise<T>
4
4
  export declare function tick(): number;
5
5
  /** Ask again every key reading one of `aliases` — every query key where none is named; an unmounted one on its next mount. */
6
6
  export declare function refetchReads(aliases: readonly string[] | undefined): void;
7
+ /** `refetchReads`, settled once every key a screen reads now holds an answer asked after this call — or its refusal. */
8
+ export declare function readsAnswered(aliases: readonly string[]): Promise<void>;
7
9
  /** The host's push, naming the aliases whose tables moved: each key reading one asked again (`refetchReads`), in one
8
10
  * paint. */
9
11
  export declare function refetchPushed(aliases: readonly string[]): void;
package/dist/viewer.d.ts CHANGED
@@ -10,6 +10,8 @@ export declare function readContext(): Promise<AppContext>;
10
10
  */
11
11
  export declare function useAppContext(): {
12
12
  memberId: string | null;
13
+ /** The viewer is a signed-in member of the app's organization: the host named them and told their groups. */
14
+ insider: boolean;
13
15
  /** The viewer's member groups, by id; `null` where the host did not say. */
14
16
  groupIds: readonly string[] | null;
15
17
  /** Who in the organization is away, or will be, and who stands in; `null` where the host did not say. */
@@ -373,7 +373,7 @@ table's id: read it off a row of the query that renders the badges (`rows[0]?.__
373
373
  rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
374
374
  row is on screen — no request is sent and `counts` is `{}`.
375
375
 
376
- ## Record activity: `useRecordActivity`
376
+ ## Record activity: `useRecordActivity`, `undoRecordChange`
377
377
 
378
378
  Who changed which of a record's fields, and who commented on it — one entry per save
379
379
  (`dist/activity.d.ts`). For a JSON app: `entity` is the alias the app's spec names the record's
@@ -383,6 +383,8 @@ entity under.
383
383
  const { entries, next, loading, error, available, refetch } = useRecordActivity("job", recordId);
384
384
  // The page past it, mounted where the reader asks for more:
385
385
  const older = useRecordActivity("job", recordId, { after: next });
386
+ // One change entry put back, as the viewer's own save:
387
+ const { reverted, kept } = await undoRecordChange("job", recordId, entry.id);
386
388
  ```
387
389
 
388
390
  - **Authority: the app's own read of the record.** The server reaches the record through the
@@ -392,8 +394,8 @@ const older = useRecordActivity("job", recordId, { after: next });
392
394
  entry. A field taken out of the app while its table keeps it stays hidden; a field gone from its
393
395
  table appears where the version serving when it was written read it, marked `removed`. Comments
394
396
  appear only where the app declares `comments`.
395
- - **Members of the app's organization only** — the trail names its people. Standalone, `available`
396
- is `false` and nothing is read.
397
+ - **Members of the app's organization only** — the trail names its people. For a public visitor,
398
+ a member of another organization, and standalone, `available` is `false` and nothing is asked.
397
399
  - **One entry per save.** Edits by one actor on one record less than ten minutes apart read as one
398
400
  entry: each field from its value before the first edit to its value after the last, a field edited
399
401
  back to where it began dropped (and an entry left with nothing). A comment, another actor or a
@@ -401,7 +403,9 @@ const older = useRecordActivity("job", recordId, { after: next });
401
403
  its maker's edits after it, however soon, are entries of their own. `at` is the entry's first
402
404
  write, `until` its last.
403
405
  - **An entry** (`ActivityEntry`) is `kind: "change"` with `changes`, or `kind: "comment"` with
404
- `comment: { id, body, mentions }`. `actor`: `{ kind, id, name, image? }` — `member` (a person),
406
+ `comment: { id, body, mentions }`. A change entry also says `undoable` — the viewer may put it
407
+ back — `undone: { by, at }` once someone has, and `undo: true` where it is itself the save that
408
+ put an earlier one back. `actor`: `{ kind, id, name, image? }` — `member` (a person),
405
409
  `agent` (an AI acting for a member: the chat, an app's agent, an MCP client — named by that
406
410
  member), `workflow` (a workflow on its own, named by the workflow) or `import` (a script over the
407
411
  API or the CLI, named by the member whose key ran it). `name` is empty where nothing resolves it.
@@ -411,6 +415,17 @@ const older = useRecordActivity("job", recordId, { after: next });
411
415
  `[{ key, label }]` (`readSelect`; each option's label as it was when written), a member field
412
416
  `[{ id, name, image? }]`, a link `[{ id, display }]` (`readLinks`), files presigned (`readFiles`),
413
417
  `null` for empty.
418
+ - **`undoRecordChange(entity, record_id, entry_id)`** puts one change entry back: each field it
419
+ changed that the app reads returns to what it held before the entry, where the record still holds
420
+ what the entry wrote. It answers `{ reverted, kept }`: the fields put back (`{ field_id, label }`),
421
+ and those left as they stand with a `reason` — `changed_since` (someone changed it since) or
422
+ `refused` (the table refused the write: a lock, a check). A field already holding what it held
423
+ before is in neither. The write is the viewer's own save: they hold `write_data` on the table as
424
+ the call arrives, on a row their own row rule covers, and the record's lock, the table's checks
425
+ and its write rules answer as for any save of theirs. `undoable` is `false`, and the call
426
+ refused, for the entry that made the record, an entry that is an undo, one already put back, and
427
+ a viewer who may not write the record.
428
+ The undo is an entry of its own; the answer arrives once the record's reads are read anew.
414
429
  - **Pages**: newest first, about thirty entries each; `next` is where the trail goes on —
415
430
  `undefined` at its end. A page may hold fewer entries and still say `next` where the trail it read
416
431
  held little the reader may see.
@@ -428,4 +443,5 @@ const older = useRecordActivity("job", recordId, { after: next });
428
443
  | `useViewer` | member id (view-as target) | `null` |
429
444
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
430
445
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
431
- | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false` |
446
+ | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false`, nothing asked |
447
+ | `undoRecordChange` | ✓ a member holding the table's write, on a row they may write | ✗ rejects |
package/docs/runtime.md CHANGED
@@ -210,6 +210,7 @@ surfaces:
210
210
  |---|---|---|
211
211
  | `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes |
212
212
  | `comments.*` | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
213
+ | `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."` |
213
214
  | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes |
214
215
  | `agentRun.get`, `agentRun.cancel` | yes | yes |
215
216
  | `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 …"`) |
package/docs/security.md CHANGED
@@ -103,6 +103,7 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
103
103
  | 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
104
  | Comments (`useComments`) | **No** — members-only |
105
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 |
106
107
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
107
108
 
108
109
  **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
@@ -392,7 +392,7 @@ body does not look like what you typed.
392
392
  | `arr.at(i)` | `at(arr, i)` — negative counts from the end |
393
393
  | `arr.filter(Boolean)` | `compact(arr)` — drops every falsy element |
394
394
  | `[...a, b]` | `concat(a, [b])` |
395
- | `{ ...a, b: 1 }` | `merge(a, { b: 1 })` — later sources win, as in JS |
395
+ | `{ ...a, b: 1 }` | `merge(merge({}, a), { b: 1 })` — later sources win, as in JS — and reads back as the spread, typed as you wrote it |
396
396
  | `{ table_id }` | `{ table_id: table_id }` — in expressions **and** tool inputs |
397
397
  | `undefined` | `null` |
398
398
  | `xs.push(a, b);` | `xs = concat(xs, [a, b])` — statement form |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.120.3",
3
+ "version": "0.121.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": {