@lotics/app-sdk 0.52.4 → 0.53.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/dist/src/row.d.ts +7 -3
- package/dist/src/row.js +13 -7
- package/docs/ai.md +1 -1
- package/docs/data_fetching.md +12 -4
- package/docs/mutations.md +53 -16
- package/package.json +1 -1
package/dist/src/row.d.ts
CHANGED
|
@@ -24,7 +24,9 @@ declare function bool(v: unknown): boolean;
|
|
|
24
24
|
/**
|
|
25
25
|
* Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
|
|
26
26
|
* calendar/gantt placement never shifts across timezones. Parses the leading
|
|
27
|
-
* `YYYY-MM-DD` of the serialized string
|
|
27
|
+
* `YYYY[-MM[-DD]]` of the serialized string — a reduced-precision `date` value
|
|
28
|
+
* ("2026-05" / "2026") decodes to its PERIOD START (missing month/day → 1)
|
|
29
|
+
* rather than vanishing to null. Null only if absent or unparseable. (Range
|
|
28
30
|
* fields are not handled here — they have no consumer yet.)
|
|
29
31
|
*/
|
|
30
32
|
declare function date(v: unknown): Date | null;
|
|
@@ -34,8 +36,10 @@ declare function date(v: unknown): Date | null;
|
|
|
34
36
|
* value is a timezone-less workspace wall-clock (see the serialization note
|
|
35
37
|
* above), so it is read verbatim — no UTC conversion. Use this when the time
|
|
36
38
|
* matters (a check-in time, an appointment); `date` keeps only the calendar day.
|
|
37
|
-
* Parses `YYYY-MM-DD` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
38
|
-
* is
|
|
39
|
+
* Parses `YYYY[-MM[-DD]]` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
40
|
+
* month/day is the period start (1) and a missing time is midnight — a
|
|
41
|
+
* reduced-precision value decodes rather than vanishing. Null if absent or
|
|
42
|
+
* unparseable.
|
|
39
43
|
*/
|
|
40
44
|
declare function datetime(v: unknown): Date | null;
|
|
41
45
|
/** A linked record cell entry — the target record's id + its display text. */
|
package/dist/src/row.js
CHANGED
|
@@ -53,12 +53,16 @@ function bool(v) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
|
|
55
55
|
* calendar/gantt placement never shifts across timezones. Parses the leading
|
|
56
|
-
* `YYYY-MM-DD` of the serialized string
|
|
56
|
+
* `YYYY[-MM[-DD]]` of the serialized string — a reduced-precision `date` value
|
|
57
|
+
* ("2026-05" / "2026") decodes to its PERIOD START (missing month/day → 1)
|
|
58
|
+
* rather than vanishing to null. Null only if absent or unparseable. (Range
|
|
57
59
|
* fields are not handled here — they have no consumer yet.)
|
|
58
60
|
*/
|
|
59
61
|
function date(v) {
|
|
60
|
-
const m = /^(\d{4})
|
|
61
|
-
|
|
62
|
+
const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/.exec(text(v));
|
|
63
|
+
if (!m)
|
|
64
|
+
return null;
|
|
65
|
+
return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1);
|
|
62
66
|
}
|
|
63
67
|
/**
|
|
64
68
|
* Date/datetime field → a LOCAL Date that KEEPS the stored wall-clock time, so
|
|
@@ -66,14 +70,16 @@ function date(v) {
|
|
|
66
70
|
* value is a timezone-less workspace wall-clock (see the serialization note
|
|
67
71
|
* above), so it is read verbatim — no UTC conversion. Use this when the time
|
|
68
72
|
* matters (a check-in time, an appointment); `date` keeps only the calendar day.
|
|
69
|
-
* Parses `YYYY-MM-DD` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
70
|
-
* is
|
|
73
|
+
* Parses `YYYY[-MM[-DD]]` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
74
|
+
* month/day is the period start (1) and a missing time is midnight — a
|
|
75
|
+
* reduced-precision value decodes rather than vanishing. Null if absent or
|
|
76
|
+
* unparseable.
|
|
71
77
|
*/
|
|
72
78
|
function datetime(v) {
|
|
73
|
-
const m = /^(\d{4})
|
|
79
|
+
const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?(?:[ T](\d{2}):(\d{2}))?/.exec(text(v));
|
|
74
80
|
if (!m)
|
|
75
81
|
return null;
|
|
76
|
-
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
|
|
82
|
+
return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1, m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
|
|
77
83
|
}
|
|
78
84
|
/** One `{ id, display }` object → ResolvedLink, or null if absent/malformed. */
|
|
79
85
|
function asLink(v) {
|
package/docs/ai.md
CHANGED
|
@@ -130,7 +130,7 @@ Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`k
|
|
|
130
130
|
|
|
131
131
|
The poll is bounded at 22 minutes — deliberately PAST the server's 20-minute hard run cap, so a live run always settles before the client gives up. A row still `running` at the deadline means the run's process died mid-flight (e.g. a crash that skipped the server's shutdown drain); the poll surfaces an error and the server's reaper repairs the row. Only when no run id was ever received (the run never started) does the failure reject before any polling.
|
|
132
132
|
|
|
133
|
-
On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`;
|
|
133
|
+
On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; the full settled answer is persisted server-side but is not currently client-readable (`useAgentRuns` can't reach it on any transport — see the limitation below), so treat the streamed prefix as terminal for now.
|
|
134
134
|
|
|
135
135
|
### Sessions
|
|
136
136
|
|
package/docs/data_fetching.md
CHANGED
|
@@ -228,7 +228,7 @@ Wire shapes per output column type:
|
|
|
228
228
|
| `text` | `string` |
|
|
229
229
|
| `number` | `number` (numeric database strings are normalized server-side; a rare decimal too precise for float64 stays a string — `row.num` parses both) |
|
|
230
230
|
| `boolean` | `boolean` |
|
|
231
|
-
| `date` | `"YYYY-MM-DD"` — a calendar day, no time component |
|
|
231
|
+
| `date` | `"YYYY-MM-DD"` — a calendar day, no time component (a reduced-precision date field may also carry `"YYYY-MM"` or `"YYYY"`; see below) |
|
|
232
232
|
| `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
|
|
233
233
|
| `select` | `Array<{ key, label }>` — one entry per selected option |
|
|
234
234
|
| `select_member` | `Array<{ id, name, email? }>` — `email` only for authenticated viewers of the app's own org |
|
|
@@ -249,8 +249,8 @@ metadata. A grouped query collapses rows and emits none of these. Details:
|
|
|
249
249
|
| `row.text(cell)` | any → `string` | strings pass through, finite numbers stringify, everything else → `""` |
|
|
250
250
|
| `row.num(cell)` | any → `number` | numbers pass (NaN/Infinity → 0), parseable strings parse, everything else → `0` |
|
|
251
251
|
| `row.bool(cell)` | any → `boolean` | `true` or the string `"true"`; everything else `false` |
|
|
252
|
-
| `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped |
|
|
253
|
-
| `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight |
|
|
252
|
+
| `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped; a reduced-precision value decodes to its **period start** |
|
|
253
|
+
| `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight; a reduced-precision value decodes to its **period start** |
|
|
254
254
|
| `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
|
|
255
255
|
| `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
|
|
256
256
|
| `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
|
|
@@ -262,7 +262,7 @@ All readers are pure (`unknown` in, value out), never throw, and return their em
|
|
|
262
262
|
(`null` / `""` / `0` / `false` / `[]`) for absent or malformed input — so callers iterate and
|
|
263
263
|
render without null-check pyramids.
|
|
264
264
|
|
|
265
|
-
**`row.date` vs `row.datetime`.** `row.date` parses
|
|
265
|
+
**`row.date` vs `row.datetime`.** `row.date` parses the leading `YYYY[-MM[-DD]]` and builds a
|
|
266
266
|
LOCAL-midnight `Date` — calendar/gantt placement never shifts across viewer timezones. `row.datetime`
|
|
267
267
|
keeps the stored wall-clock verbatim (no UTC conversion — the stored value is a timezone-less
|
|
268
268
|
workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time as written. A
|
|
@@ -270,6 +270,14 @@ workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time
|
|
|
270
270
|
and the UI prints `00:00`. When you need the time, the query must output the column as `datetime`
|
|
271
271
|
(the type-override rules are in [./queries.md](./queries.md)).
|
|
272
272
|
|
|
273
|
+
**Reduced-precision date values.** A `date` field may store an ISO 8601 truncated value —
|
|
274
|
+
`"2026-05"` (month) or `"2026"` (year) — when a document carries only that precision. Both decoders
|
|
275
|
+
resolve it to its **period start** (missing month/day → 1): `row.date("2026-05")` →
|
|
276
|
+
May 1 2026 at local midnight, `row.date("2026")` → Jan 1 2026 — never `null`. For DISPLAY that
|
|
277
|
+
respects the precision (show "05/2026", not "01/05/2026"), format with `@lotics/ui`'s `formatDate`
|
|
278
|
+
on the raw cell string rather than the decoded `Date`. Sorting/filtering server-side already treats
|
|
279
|
+
a partial as its period start.
|
|
280
|
+
|
|
273
281
|
**`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
|
|
274
282
|
`useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
|
|
275
283
|
`label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `OptionBadge`
|
package/docs/mutations.md
CHANGED
|
@@ -128,12 +128,16 @@ A workflow body ends with `return({ status, message?, data? })`. `data` is arbit
|
|
|
128
128
|
structured data (computed totals, row lists, status objects) the app reads back as
|
|
129
129
|
`result.data`:
|
|
130
130
|
|
|
131
|
-
- **Typed for free
|
|
132
|
-
TypeScript type of the body's `return({ data })` — the return *is*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
- **Typed for free — persisted at `set`.** The alias's `outputs` schema is derived at save
|
|
132
|
+
time from the inferred TypeScript type of the body's `return({ data })` — the return *is*
|
|
133
|
+
the declaration. When the manifest declared **no** `outputs`, `lotics app workflow set`
|
|
134
|
+
writes the server-derived schema into `package.json#lotics.workflows.<alias>.outputs` and
|
|
135
|
+
refreshes that alias's generated types in the same command, so `result.data` is typed
|
|
136
|
+
immediately — no hand-copy of the echoed schema, no second `lotics app codegen`. An
|
|
137
|
+
**explicitly declared** `outputs` is authoritative and never overwritten. Declare an explicit
|
|
138
|
+
`outputs` (same recursive vocabulary as inputs: scalars plus nested `object`/`array`, no
|
|
139
|
+
member/file/date_range) only to narrow beyond what's inferred; a shape the checker can't pin
|
|
140
|
+
down degrades to untyped `json`, never to a wrong schema.
|
|
137
141
|
- **Validated at run.** On a success return, the returned `data` is validated against the
|
|
138
142
|
schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
|
|
139
143
|
message, so a declared output is a real contract. An *error* return's `data` passes through
|
|
@@ -173,7 +177,7 @@ Every declaration takes optional `description` and `required` (default **true**)
|
|
|
173
177
|
| `date` | — | `"YYYY-MM-DD"`, or a TZ-bearing ISO datetime (projected to the workspace day) | `string` |
|
|
174
178
|
| `datetime` | — | `"YYYY-MM-DDTHH:mm"` naive workspace wall-clock (what the platform DatePicker emits), or TZ-bearing ISO | `string` |
|
|
175
179
|
| `email` | — | a valid email | `string` |
|
|
176
|
-
| `select` | `options: [{label, value}]` (min 1)
|
|
180
|
+
| `select` | exactly one of `options: [{label, value}]` (min 1) **or** `field: "fld_…"`; `multi?` | an option key (array when `multi`) | union of the option `value`s (inline) or the field's current option keys (`field`); `ReadonlyArray<…>` when `multi` |
|
|
177
181
|
| `record_link` | `table_id` (required), `multi?` | a record id — must exist **in the declared table** | `string` / `ReadonlyArray<string>` |
|
|
178
182
|
| `member` | `group?`, `multi?` | a member id — with `group`, must belong to that group | `string` / `ReadonlyArray<string>` |
|
|
179
183
|
| `file` | `multi?` | a file id from an upload — must live in the app's workspace | `string` / `ReadonlyArray<string>` |
|
|
@@ -209,12 +213,24 @@ When the alias declares `inputs`, the server validates the payload before the wo
|
|
|
209
213
|
declared group; every `file` id must live in the app's workspace. These are real write-time
|
|
210
214
|
constraints, not picker cosmetics — a hand-crafted request can't redirect the workflow.
|
|
211
215
|
Rationale and the full caller-boundary model: [security](./security.md).
|
|
212
|
-
- **`select`
|
|
213
|
-
`options
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
- **`select` names its option set one of two ways — declare exactly one (both or neither is a
|
|
217
|
+
set-time error).** *Inline* `options: [{label, value}]` freezes the set into the codegen
|
|
218
|
+
literal union and is **format-only at run time**: any well-formed key is accepted, and an
|
|
219
|
+
option added to the live field after deploy is valid at run time but fails the compile-time
|
|
220
|
+
type (widen or redeploy the declaration when the frozen union gets in the way).
|
|
221
|
+
*Field-referenced* `field: "fld_…"` (a select field's global key — `fld_…`, no table prefix)
|
|
222
|
+
is **drift-proof**: the union is resolved from the field's *current* options at type-gen, and
|
|
223
|
+
the submitted value is validated against the field's *current* options at run time — an option
|
|
224
|
+
added after authoring is accepted, a removed one rejected
|
|
225
|
+
(`select input "<path>" value "<v>" is not one of field "<key>"'s current options`) with no
|
|
226
|
+
redeploy. A `field` that doesn't exist or names a non-select field is rejected at bind time
|
|
227
|
+
(`lotics app workflow set` / `set_app_workflow`; `lotics app deploy` never binds workflows) with
|
|
228
|
+
`select input references field "<key>", which does not exist in this workspace` /
|
|
229
|
+
`… which is a <type> field, not a select`. Prefer `field` for any select backed by a real
|
|
230
|
+
field; keep `options` for a fixed enum the app owns or a select you plan to **package** — a
|
|
231
|
+
`field`-form select can't ride a package contract (it carries a concrete field id), so inline
|
|
232
|
+
the options before publishing. Populate pickers from `useFieldOptions` either way (the live
|
|
233
|
+
option set — see [members_and_options](./members_and_options.md)).
|
|
218
234
|
|
|
219
235
|
If the alias declares **no** `inputs`, the payload passes through opaquely — no validation,
|
|
220
236
|
no typing, no reference binding. Fine for a zero-input action; declare inputs for anything
|
|
@@ -254,6 +270,27 @@ if (i.title != null) {
|
|
|
254
270
|
}
|
|
255
271
|
```
|
|
256
272
|
|
|
273
|
+
When several optional inputs each map to a field, per-field guards become noise. Hand the whole
|
|
274
|
+
bag to `update_records`' **`set_skip_null`** instead — same object shape as `set`, but entries
|
|
275
|
+
whose value is `null`/`undefined` are dropped, so only the fields actually provided get written
|
|
276
|
+
(untouched fields keep their lock / `before_update` / concurrent-edit safety — the diff-write
|
|
277
|
+
discipline below, done in the body):
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
const i = trigger.app_workflow.inputs;
|
|
281
|
+
await update_records({
|
|
282
|
+
table_id: "tbl_items",
|
|
283
|
+
record_ids: [i.record_id],
|
|
284
|
+
set_skip_null: { fld_title: i.title, fld_status: i.status, fld_due: i.due }, // absent inputs drop out
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`set` still writes every key it carries — `null` in `set` **clears** the field; `null`/absent in
|
|
289
|
+
`set_skip_null` **skips** it. That is the per-field "clear vs. leave unchanged" choice, so a field
|
|
290
|
+
may appear in **at most one** of `set` / `set_skip_null` (naming it in both is rejected at run
|
|
291
|
+
time). An all-absent `set_skip_null` with no other write surface is a no-op — no records touched,
|
|
292
|
+
no `before_update` hooks.
|
|
293
|
+
|
|
257
294
|
## Refetch after a mutation
|
|
258
295
|
|
|
259
296
|
Query hooks cache through SWR and know nothing about your workflows — a successful mutation
|
|
@@ -281,9 +318,9 @@ in place of an explicit refetch after a write the user is watching for.
|
|
|
281
318
|
|
|
282
319
|
An edit form snapshots the record's values when it loads, and on save sends **only the fields
|
|
283
320
|
the user actually changed** to its update workflow. Declare each updatable input
|
|
284
|
-
`required: false`; an omitted input means "not written" (the body guards each write
|
|
285
|
-
above). "Changed" is decided at the edit surface that loaded
|
|
286
|
-
against the load-time snapshot, not against a re-fetch.
|
|
321
|
+
`required: false`; an omitted input means "not written" (the body guards each write — or hands
|
|
322
|
+
them to `set_skip_null` — as shown above). "Changed" is decided at the edit surface that loaded
|
|
323
|
+
the before-state — compare against the load-time snapshot, not against a re-fetch.
|
|
287
324
|
|
|
288
325
|
Why a full-form snapshot save is a bug, not a style choice — three independent mechanisms:
|
|
289
326
|
|