@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 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; null if absent or unparseable. (Range
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 time
38
- * is midnight. Null if absent or unparseable.
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; null if absent or unparseable. (Range
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})-(\d{2})-(\d{2})/.exec(text(v));
61
- return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null;
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 time
70
- * is midnight. Null if absent or unparseable.
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})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/.exec(text(v));
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`; read the full settled answer via `useAgentRuns(sessionId)` if you need it.
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
 
@@ -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 only the leading `YYYY-MM-DD` and builds a
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.** The alias's `outputs` schema is derived at save time from the inferred
132
- TypeScript type of the body's `return({ data })` — the return *is* the declaration. Codegen
133
- then types `result.data` per alias. Declare an explicit `outputs` on the alias (same
134
- recursive vocabulary as inputs: scalars plus nested `object`/`array`, no member/file/
135
- date_range) only to narrow beyond what's inferred; a shape the checker can't pin down
136
- degrades to untyped `json`, never to a wrong schema.
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), `multi?` | an option key (array when `multi`) | union of declared `value` literals; `ReadonlyArray<…>` when `multi` |
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` accepts any well-formed option key at runtime**, not only the deploy-declared
213
- `options`. **Limitation:** the declared options are frozen into the codegen literal union,
214
- so an option added to the live field after deploy is valid at runtime but fails the
215
- compile-time type. Populate pickers from `useFieldOptions` (the live option set see
216
- [members_and_options](./members_and_options.md)) and widen or redeploy the declaration when
217
- the type gets in the way.
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 as shown
285
- above). "Changed" is decided at the edit surface that loaded the before-state — compare
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.52.4",
3
+ "version": "0.53.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {