@lotics/cli 0.289.0 → 0.291.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.
@@ -62,6 +62,9 @@ async function uploadParts(options) {
62
62
  var CLI_CAPABILITIES_HEADER = "x-lotics-cli-capabilities";
63
63
  var CLI_CAPABILITIES = ["docs-fallback"];
64
64
 
65
+ // ../shared/src/proxy_upload_limit.ts
66
+ var PROXY_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
67
+
65
68
  // src/client.ts
66
69
  import crypto from "node:crypto";
67
70
  import fs from "node:fs";
@@ -126,6 +129,7 @@ function getMimeType(filename) {
126
129
  return MIME_MAP[ext] ?? "application/octet-stream";
127
130
  }
128
131
  var MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024;
132
+ var PROXY_BATCH_MAX_BYTES = PROXY_UPLOAD_MAX_BYTES / 2;
129
133
  var LoticsRequestError = class extends Error {
130
134
  constructor(message, status, body) {
131
135
  super(message);
@@ -578,25 +582,37 @@ var LoticsClient = class {
578
582
  );
579
583
  }
580
584
  async uploadFiles(filePaths, options) {
581
- const small = [];
585
+ const files = [];
586
+ const errors = [];
582
587
  const large = [];
588
+ let batch = [];
589
+ let batchBytes = 0;
590
+ const sendBatch = async () => {
591
+ const sent = batch;
592
+ batch = [];
593
+ batchBytes = 0;
594
+ try {
595
+ const result = await this.uploadFileBytes(sent);
596
+ files.push(...result.files);
597
+ errors.push(...result.errors);
598
+ } catch (error) {
599
+ const message = error instanceof Error ? error.message : String(error);
600
+ errors.push(...sent.map((item) => ({ filename: item.filename, error: message })));
601
+ }
602
+ };
583
603
  for (let i = 0; i < filePaths.length; i++) {
584
604
  const absolutePath = path.resolve(filePaths[i]);
585
605
  const filename = options?.filenames?.[i] ?? path.basename(absolutePath);
586
606
  const { size } = await fs.promises.stat(absolutePath);
587
607
  if (size >= MULTIPART_THRESHOLD_BYTES) {
588
608
  large.push({ absolutePath, filename, size });
589
- } else {
590
- small.push({ bytes: await fs.promises.readFile(absolutePath), filename });
609
+ continue;
591
610
  }
611
+ if (batch.length > 0 && batchBytes + size > PROXY_BATCH_MAX_BYTES) await sendBatch();
612
+ batch.push({ bytes: await fs.promises.readFile(absolutePath), filename });
613
+ batchBytes += size;
592
614
  }
593
- const files = [];
594
- const errors = [];
595
- if (small.length > 0) {
596
- const result = await this.uploadFileBytes(small);
597
- files.push(...result.files);
598
- errors.push(...result.errors);
599
- }
615
+ if (batch.length > 0) await sendBatch();
600
616
  for (const item of large) {
601
617
  try {
602
618
  files.push(await this.uploadLargeFile(item));
@@ -31,7 +31,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
31
31
  | — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |
32
32
  | `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |
33
33
  | `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes — harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |
34
- | `lotics file upload <file\|dir...>` (alias `lotics upload`) · `--stdin` · `--base64` · `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files` in one request, and several such files go in the same one; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose — one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
34
+ | `lotics file upload <file\|dir...>` (alias `lotics upload`) · `--stdin` · `--base64` · `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files`, several such files to a request up to half that route's 100 MiB cap; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose — one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
35
35
  | `lotics file download <file_id> [<path>]` · `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there — the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once — a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_key> [-o <dir>]` spreads every file on a record's file field over a DIRECTORY — there is no single file for N files to be. |
36
36
  | `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first — id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset — pass it back as given — so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to "what is in here" short of reading Postgres. |
37
37
  | `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. **Refused while a record cell, a comment, a knowledge doc, a document template or a voice session still references it** — the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what "deleted" means here. There is no `lotics delete`: the verb needs its noun. |
@@ -129,8 +129,8 @@ What `create_table` (on the CLI or in chat) and `update_table` take in `add_fiel
129
129
  `select_record_link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`. `button` is retired: an
130
130
  existing button field keeps running, but none is created, converted to or edited.
131
131
 
132
- A URL, an email, markdown, a checkbox, a datetime, a currency or a percentage is a `format` on
133
- another type, never a `type`:
132
+ A URL, an email, markdown, a checkbox, a datetime, money, a quantity or a percentage is a `format`
133
+ or a `notation` on another type, never a `type`:
134
134
 
135
135
  | Wanted | Field |
136
136
  |---|---|
@@ -138,8 +138,9 @@ another type, never a `type`:
138
138
  | Email or phone | `{ type: "text" }` |
139
139
  | Markdown | `{ type: "text", format: "markdown" }` |
140
140
  | Checkbox | `{ type: "boolean" }` |
141
- | Money | `{ type: "number", format: "currency", currency: "USD" }` |
142
- | Percentage | `{ type: "number", format: "percentage" }` |
141
+ | Money | `{ type: "number", notation: { style: "currency", currency: "USD" } }` |
142
+ | Quantity | `{ type: "number", notation: { style: "unit", unit: "kg" } }` |
143
+ | Percentage | `{ type: "number", notation: { style: "unit", unit: "percent" } }` |
143
144
  | Datetime | `{ type: "date", format: "datetime" }` |
144
145
  | Date range | `{ type: "date", format: "date_range" }` |
145
146
 
@@ -150,14 +151,18 @@ may itself hold an object (`formula`, `aggregate_option`, `filter`, `order_by`);
150
151
  inside it. Each type takes only its own:
151
152
 
152
153
  - **text** — `format?` (`"text"` | `"link"` | `"markdown"`), `unique?`, `default_value?` (a string).
153
- - **number** — `format?` (`"number"` | `"currency"` | `"percentage"`), `currency?` (an ISO 4217
154
- code), `unit?`, `unit_field?`, `currency_field?`, `default_value?` (a number). On `update_fields`,
155
- null clears `currency`, `unit`, `unit_field` or `currency_field`.
156
- - `unit`, beside format `"number"`: a measured code — g, kg, t, l, m3, cbm, mm, cm, m, km, m2,
157
- min, h, day — or a counted noun such as `kiện`. A change between two units of one dimension
158
- converts every stored figure; any other change relabels.
159
- - `unit_field`: A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row's unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number".
160
- - `currency_field`: A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row's currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency".
154
+ - **number** — `notation?`, `default_value?` (a number). `notation` is how the figure reads, one
155
+ of three; on `update_fields` a stated one replaces the field's whole.
156
+ - `{ style: "decimal" }` — a plain number, as a field stating none reads.
157
+ - `{ style: "currency", currency: "VND" }` — money in an ISO 4217 code.
158
+ - `{ style: "unit", unit }` — a quantity: `"percent"` (10 reads "10%", never multiplied), a
159
+ measured code — g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day — or a counted noun such
160
+ as `kiện`. A change between two units of one dimension converts every stored figure; any other
161
+ change relabels.
162
+ - A currency or unit read on each row is `{ per_row: "ĐVT" }` in its place: a single select on
163
+ the same row, or a lookup of one through a one-link, named by name or key, whose chosen option
164
+ is that row's currency or unit — every option label an ISO 4217 code, or a unit as `unit`
165
+ takes one.
161
166
  - **date** — `format?` (`"date"` | `"datetime"` | `"date_range"` | `"datetime_range"`), `timezone?`,
162
167
  `derive_from?`, `default_value?` (a date string). `derive_from: "created_at"` stamps the row's
163
168
  creation once, `"updated_at"` re-stamps on every update; the field is then read-only, takes no
@@ -186,7 +191,7 @@ not exist yet, and option keys (`opt_…`) in `update_fields`.
186
191
  Read-only in records. `formula` is one key holding an object; a rollup's and a lookup's properties
187
192
  are separate keys on the field — `rollup: {…}` and `lookup: {…}` are refused as unrecognized.
188
193
 
189
- formula: { expression, format?, currency?, unit?, unit_field?, currency_field? }
194
+ formula: { expression, notation?, link? }
190
195
 
191
196
  - `{Field Name}` or `{fld_key}` names a field of the same table, stored as its key, so a rename never
192
197
  breaks the formula. A reference to no field, or a call to something that is no helper, is refused.
@@ -194,9 +199,10 @@ formula: { expression, format?, currency?, unit?, unit_field?, currency_field? }
194
199
  `includes({Tags}, "opt_…")` for a multi.
195
200
  - The operators, the helpers and how an empty cell reads are the `model` reference, section `formula` — a model
196
201
  names fields by alias where a table tool names them by name or key; the language is the same.
197
- - On `update_fields` a key left out keeps its stored value, and null clears `currency`, `unit`,
198
- `unit_field` or `currency_field`. `get_table` marks a formula that is null when every field it
199
- reads is empty.
202
+ - `notation` is how a number result reads, as a number field's; `link: true` says the result is a
203
+ URL, drawn as a link that opens.
204
+ - On `update_fields` a key left out keeps its stored value, and a stated `notation` replaces the
205
+ stored one whole. `get_table` marks a formula that is null when every field it reads is empty.
200
206
 
201
207
  rollup — `source_field_key`, `aggregate_option: { field_key?, operation }`, `filter?`
202
208
 
@@ -207,9 +213,10 @@ rollup — `source_field_key`, `aggregate_option: { field_key?, operation }`, `f
207
213
  `min`, `max`, `range`; date: `earliest`, `latest`, `date_range`.
208
214
  - The cell's type comes from the OPERATION: `earliest` and `latest` hold a date, every other
209
215
  operation a number (`date_range` counts days). A "most recent linked date" is `latest` — `min` and
210
- `max` are numeric and refuse a date. A rollup takes no format, currency or unit of its own: `sum`
211
- over money carries the aggregated field's currency, over a weight its unit.
212
- - Over a figure read in each row's own unit or currency (`unit_field` / `currency_field`), `sum`,
216
+ `max` are numeric and refuse a date. A rollup takes no notation of its own: `sum` over money
217
+ reads in the aggregated field's currency, over a weight in its unit, a count as a plain number
218
+ and a `percent_*` in the unit `percent`.
219
+ - Over a figure read in each row's own unit or currency (a notation's `per_row`), `sum`,
213
220
  `avg`, `median`, `min`, `max` and `range` hold only where that select is a lookup, through the
214
221
  link paired with `source_field_key`, of a single select on this table — the total reads in this
215
222
  row's option of it. A lookup of such a field has no unit.
@@ -227,13 +234,14 @@ lookup — `source_field_key`, `lookup_field_key`, `order_by?`
227
234
  ### Examples
228
235
 
229
236
  ```
230
- { name: "Giá bán", type: "number", format: "currency", currency: "VND" }
231
- { name: "Trọng lượng", type: "number", unit: "kg" }
232
- { name: "Số lượng", type: "number", unit_field: "ĐVT" }
237
+ { name: "Giá bán", type: "number", notation: { style: "currency", currency: "VND" } }
238
+ { name: "Trọng lượng", type: "number", notation: { style: "unit", unit: "kg" } }
239
+ { name: "Số lượng", type: "number", notation: { style: "unit", unit: { per_row: "ĐVT" } } }
240
+ { name: "Chiết khấu", type: "number", notation: { style: "unit", unit: "percent" } }
233
241
  { name: "Trạng thái", type: "select", options: [{ name: "Mới" }, { name: "Xong" }] }
234
242
  { name: "Mã đơn", type: "autonumber", template: "SR-{YEAR}-{N:4}" }
235
243
  { name: "Khách hàng", type: "select_record_link", table_id: "tbl_x", sync_both_ways: true }
236
- { name: "Total", type: "formula", formula: { expression: "{Price} * {Qty}", format: "currency", currency: "VND" } }
244
+ { name: "Total", type: "formula", formula: { expression: "{Price} * {Qty}", notation: { style: "currency", currency: "VND" } } }
237
245
  { name: "SL đã giao", type: "rollup", source_field_key: "Giao hàng", aggregate_option: { operation: "sum", field_key: "Số lượng" } }
238
246
  { name: "Customer Name", type: "lookup", source_field_key: "Customer", lookup_field_key: "Name" }
239
247
  { name: "Latest note", type: "lookup", source_field_key: "Calls", lookup_field_key: "Note", order_by: { field_key: "At", direction: "desc" } }
package/docs/examples.md CHANGED
@@ -35,7 +35,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
35
35
  {"alias": "booked_on", "type": "date"},
36
36
  {"alias": "parts_ready_on", "type": "date"},
37
37
  {"alias": "arrived_on", "type": "date"},
38
- {"alias": "hours", "type": "number", "format": "number", "unit": "h"},
38
+ {"alias": "hours", "type": "number", "notation": {"style": "unit", "unit": "h"}},
39
39
  {"alias": "report", "type": "text", "format": "markdown"},
40
40
  {"alias": "completed_on", "type": "date"}
41
41
  ]
@@ -120,7 +120,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
120
120
  {"alias": "booked_on", "type": "date"},
121
121
  {"alias": "parts_ready_on", "type": "date"},
122
122
  {"alias": "arrived_on", "type": "date"},
123
- {"alias": "hours", "type": "number", "format": "number", "unit": "h"},
123
+ {"alias": "hours", "type": "number", "notation": {"style": "unit", "unit": "h"}},
124
124
  {"alias": "completed_on", "type": "date"}
125
125
  ]
126
126
  },
@@ -379,7 +379,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
379
379
  },
380
380
  {"alias": "on_hand", "type": "number"},
381
381
  {"alias": "minimum", "type": "number"},
382
- {"alias": "price", "type": "number", "format": "currency", "currency": "USD"},
382
+ {"alias": "price", "type": "number", "notation": {"style": "currency", "currency": "USD"}},
383
383
  {"alias": "supplier_page", "type": "text", "format": "link"},
384
384
  {"alias": "last_counted", "type": "date"},
385
385
  {"alias": "counted_by", "type": "select_member"},
@@ -394,7 +394,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
394
394
  {"alias": "quantity", "type": "number"},
395
395
  {"alias": "shipped", "type": "number"},
396
396
  {"alias": "picked", "type": "number"},
397
- {"alias": "unit_price", "type": "number", "format": "currency", "currency": "USD"},
397
+ {"alias": "unit_price", "type": "number", "notation": {"style": "currency", "currency": "USD"}},
398
398
  {"alias": "amount", "type": "formula"},
399
399
  {"alias": "category", "type": "lookup"}
400
400
  ]
@@ -493,7 +493,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
493
493
  {"alias": "phone", "type": "text"},
494
494
  {"alias": "website", "type": "text", "format": "link"},
495
495
  {"alias": "account_manager", "type": "select_member"},
496
- {"alias": "credit_limit", "type": "number", "format": "currency", "currency": "USD"},
496
+ {"alias": "credit_limit", "type": "number", "notation": {"style": "currency", "currency": "USD"}},
497
497
  {"alias": "outstanding", "type": "rollup"},
498
498
  {"alias": "over_limit", "type": "formula"},
499
499
  {
@@ -746,7 +746,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
746
746
  {"alias": "sale", "type": "select_member"},
747
747
  {"alias": "xu_ly", "type": "select_member"},
748
748
  {"alias": "can_bo_sung", "type": "boolean"},
749
- {"alias": "phi_dich_vu", "type": "number", "format": "currency", "currency": "VND"},
749
+ {"alias": "phi_dich_vu", "type": "number", "notation": {"style": "currency", "currency": "VND"}},
750
750
  {
751
751
  "alias": "loai_can",
752
752
  "type": "select",
@@ -758,7 +758,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
758
758
  ]
759
759
  },
760
760
  {"alias": "can_boc", "type": "text"},
761
- {"alias": "hoa_hong_sale", "type": "number", "format": "currency", "currency": "VND"},
761
+ {"alias": "hoa_hong_sale", "type": "number", "notation": {"style": "currency", "currency": "VND"}},
762
762
  {"alias": "ngay_thu_phi", "type": "date"},
763
763
  {"alias": "ngay_nhan_giay_to", "type": "date"},
764
764
  {"alias": "ngay_lap_ho_so", "type": "date"},
@@ -812,7 +812,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
812
812
  },
813
813
  {"alias": "ngay_sinh", "type": "date"},
814
814
  {"alias": "cccd", "type": "text"},
815
- {"alias": "thu_nhap", "type": "number", "format": "currency", "currency": "VND"}
815
+ {"alias": "thu_nhap", "type": "number", "notation": {"style": "currency", "currency": "VND"}}
816
816
  ]
817
817
  },
818
818
  {
@@ -828,7 +828,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
828
828
  ]
829
829
  },
830
830
  {"alias": "ho_so", "type": "select_record_link", "target_entity": "ho_so", "cardinality": "one"},
831
- {"alias": "so_tien", "type": "number", "format": "currency", "currency": "VND"},
831
+ {"alias": "so_tien", "type": "number", "notation": {"style": "currency", "currency": "VND"}},
832
832
  {
833
833
  "alias": "hinh_thuc",
834
834
  "type": "select",
@@ -1471,7 +1471,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
1471
1471
  "target_entity": "nha_cung_cap",
1472
1472
  "cardinality": "one"
1473
1473
  },
1474
- {"alias": "so_tien", "type": "number", "format": "currency", "currency": "VND"},
1474
+ {"alias": "so_tien", "type": "number", "notation": {"style": "currency", "currency": "VND"}},
1475
1475
  {"alias": "han_thanh_toan", "type": "date"},
1476
1476
  {
1477
1477
  "alias": "trang_thai",
@@ -1520,6 +1520,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
1520
1520
  "register": {
1521
1521
  "columns": ["nguoi_de_nghi"],
1522
1522
  "filters": ["nguoi_de_nghi", "du_an"],
1523
+ "group": "du_an",
1523
1524
  "create": ["noi_dung", "loai", "du_an", "nha_cung_cap", "so_tien", "han_thanh_toan", "nguoi_de_nghi"],
1524
1525
  "readings": [
1525
1526
  {
@@ -1595,7 +1596,7 @@ Every key: the `model` reference, at the pages each section names under **Keys**
1595
1596
  "type": "select",
1596
1597
  "options": [{"alias": "thu", "color": "green"}, {"alias": "chi", "color": "rose"}]
1597
1598
  },
1598
- {"alias": "so_tien", "type": "number", "format": "currency", "currency": "VND"},
1599
+ {"alias": "so_tien", "type": "number", "notation": {"style": "currency", "currency": "VND"}},
1599
1600
  {"alias": "bien_dong", "type": "formula"},
1600
1601
  {
1601
1602
  "alias": "doi_tuong",
package/docs/filters.md CHANGED
@@ -49,8 +49,9 @@ pass the whole list in one condition rather than splitting it across calls.
49
49
  | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to` | a number |
50
50
  | `is_empty`, `is_not_empty` | none |
51
51
 
52
- A number whose field states `unit_field` or `currency_field` is read in each row's own unit or
53
- currency, so a comparison on it states `unit_option`: the option of that select its value is in.
52
+ A number whose `notation` reads its unit or currency on each row (`{ "per_row": <select> }`) is
53
+ read in each row's own, so a comparison on it states `unit_option`: the option of that select its
54
+ value is in.
54
55
  Rows holding another option are out; measured units of one dimension convert.
55
56
 
56
57
  ```
package/docs/workflows.md CHANGED
@@ -6,9 +6,10 @@ Only the forms below are accepted; anything else is refused at save with the lin
6
6
  what to write instead. A save that succeeds may still return `warnings` — advisory hints such as a
7
7
  loop that may never end. Read them.
8
8
 
9
- The same grammar serves an automation, a table's lifecycle workflow and an app's workflow. Only an
10
- automation's source opens with a trigger declaration; a lifecycle workflow takes its trigger from its
11
- table and event, an app workflow from the app that calls it.
9
+ The same grammar serves an automation, a table's lifecycle workflow and an app's workflow, and the
10
+ expressions of a table check. Only an automation's source opens with a trigger declaration; a
11
+ lifecycle workflow takes its trigger from its table and event, an app workflow from the app that calls
12
+ it.
12
13
 
13
14
  ## Triggers
14
15
 
@@ -63,10 +64,10 @@ await send_email({
63
64
  | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }? });` |
64
65
  | validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
65
66
 
66
- `return` is a call, not a JavaScript `return` statement. `validate` refuses the write when a check's
67
- `fail_when` is truthy; the check's `field_key` names the control its message lands on — a field of the
68
- trigger table in a lifecycle workflow; in an app workflow a declared input, or the `fld_` key of a
69
- field on a table the body names; in an automation it is not checked.
67
+ `return` is a call, not a JavaScript `return` statement. `validate` ends the workflow with an error
68
+ when a check's `fail_when` is truthy; the check's `field_key` names the control its message lands on —
69
+ in an app workflow a declared input, or the `fld_` key of a field on a table the body names; a field of
70
+ the trigger table in a lifecycle workflow; in an automation it is not checked.
70
71
 
71
72
  The name in `const x = await tool({...})` is the step's id; later expressions read its output as
72
73
  `x.records`, `x.id` and so on. A `// id: my_step` comment directly above a statement sets an explicit
@@ -122,8 +123,7 @@ step — summarize a record, classify, draft text.
122
123
  - `output` — `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.
123
124
 
124
125
  Only `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the
125
- `schema` declares. The agent's own tool calls are not readable. An agent step cannot give a
126
- synchronous verdict, so a `before_*` lifecycle workflow refuses it.
126
+ `schema` declares. The agent's own tool calls are not readable.
127
127
 
128
128
  ## Keys, not names
129
129
 
@@ -264,25 +264,6 @@ field when there is none. Use `coalesce(x, fallback)` only for a real fallback v
264
264
 
265
265
  ## Examples
266
266
 
267
- Refuse a duplicate before it is created (a `before_create` lifecycle workflow; the keys come from
268
- `get_table`):
269
-
270
- ```
271
- const dup = await query_records({
272
- table_id: "tbl_orders",
273
- filters: { node_type: "group", logic: "and", children: [
274
- { node_type: "condition", field_key: "fld_ref", operator: "equals", value: record["fld_ref"] },
275
- { node_type: "condition", field_key: "fld_closed_at", operator: "is_empty" },
276
- ]},
277
- });
278
-
279
- validate({ checks: [{
280
- fail_when: size(dup.records) > 0,
281
- field_key: "fld_ref",
282
- message: "An open order already has this reference.",
283
- }]});
284
- ```
285
-
286
267
  Compare a link by id. Display text is not unique, and a link reads as a list of ids — `==` against a
287
268
  string is a type error:
288
269
 
@@ -302,16 +283,14 @@ if (customer && includes(record["fld_customer"], customer.id)) {
302
283
 
303
284
  A lifecycle workflow is bound to one table and one event, and its source has no `on({...})` line:
304
285
 
305
- - `before_create` / `after_create` — a create, and a draft's submit (the moment it becomes a record).
306
- - `before_update` / `after_update` — every field edit, a draft's included: a `before_update` check
307
- runs while someone fills in a draft, not only once it is submitted.
308
- - `before_delete` / `after_delete`.
286
+ - `after_create` — a create, and a draft's submit (the moment it becomes a record).
287
+ - `after_update` — every field edit, a draft's included.
288
+ - `after_delete`.
309
289
 
310
- A `before_*` workflow runs before the write commits and may refuse it, with `validate` or
311
- `return({ status: "error", ... })`; its errors come back as field errors on the write. It cannot
312
- `wait`, `wait_for_event`, `wait_for_approval` or run an `agent` step. An `after_*` workflow runs after
313
- the commit, with every step; its errors are logged and never fail the write. An `after_update`
314
- workflow that writes its own table saves with a `loop_potential` warning: its write fires it again.
290
+ A lifecycle workflow runs after the write commits, with every step; its errors are logged and never
291
+ fail the write. What refuses a write before it lands is a table check (see **Table
292
+ checks**). An `after_update` workflow that writes its own table saves with a `loop_potential`
293
+ warning: its write fires it again.
315
294
 
316
295
  | reads | on |
317
296
  |---|---|
@@ -334,3 +313,63 @@ that cares about some events only skips the rest:
334
313
 
335
314
  In `if_source`, `runtime.change_origin` is the origin of the write that fired it; in the body, it is
336
315
  the workflow's own.
316
+
317
+ ## Table checks
318
+
319
+ A table check is one condition its table's record writes must not meet, declared beside the table's
320
+ `unique`. It is not a workflow: it reads the write and a few lookups and answers yes or no, so it
321
+ cannot change data. It runs inside the write, on every create, update or delete it is `on`, whichever
322
+ surface makes it — a person, the API, the CLI, an agent, a workflow. A refusal writes nothing and
323
+ answers with the check's message, under its field when the check names one, and names the check.
324
+
325
+ On the CLI or in chat, `set_table_check` creates one, or changes one when given its `table_check_id`
326
+ and only the parts that change, and `remove_table_check` removes one. `get_table` lists a table's
327
+ checks in the source they are written in.
328
+
329
+ | part | what it holds |
330
+ |---|---|
331
+ | `on` | the writes it checks: any of `create`, `update`, `delete` |
332
+ | `when` | optional expression; the check runs only on a write where it is truthy. It cannot read lookups. |
333
+ | `lookups` | optional, by name, the rows to read: `{ table_id, filter, limit }`, the filter as `query_records` takes it, `limit` from 1 to 50 (50 when left out). A filter value may read the record. |
334
+ | `fail_when` | expression; truthy refuses the write |
335
+ | `message` | expression for the refusal's text — a fixed text is a quoted string |
336
+ | `field_key` | optional; the field the refusal is shown under |
337
+
338
+ Every expression is written in the grammar above and reads:
339
+
340
+ - `record` — the record as it will be stored; on a delete, the record being deleted.
341
+ - `prev_record` — on an update or a delete, the record before.
342
+ - `changes` — on an update, the fields it changes.
343
+ - `lookups.<name>` — in `fail_when` and `message`, the rows that lookup read.
344
+ - `runtime.timezone`, `runtime.workspace_id`, `runtime.organization_id`, `runtime.change_origin` (the
345
+ write's origin), `runtime.triggered_by_member_id` (the member writing; null for a write no member
346
+ made), and `now()`.
347
+
348
+ A check on several operations reads only what all of them carry, so one on creates and updates reads
349
+ neither `prev_record` nor `changes`.
350
+
351
+ A lookup reads with the access of the member who last saved the check, so saving one requires reading
352
+ every table its lookups read. It reads committed rows: two writes at the same moment can each pass a
353
+ check the other would fail, so values no two rows may share are the table's `unique`, not a check.
354
+
355
+ A `when`, a lookup or a `fail_when` that cannot decide — it fails, or the write's time runs out —
356
+ refuses the write. A draft is checked when it is submitted, as a create; its edits before that are
357
+ not. A restored record is checked as a create. Cloning a table copies its checks to the member who
358
+ clones it, and is refused when that member cannot read a table a check's lookup reads.
359
+
360
+ One open order per reference, a row never counting against itself:
361
+
362
+ ```
363
+ {
364
+ "table_id": "tbl_orders",
365
+ "name": "One open order per reference",
366
+ "on": ["create", "update"],
367
+ "when": "isNull(record[\"fld_closed_at\"])",
368
+ "lookups": {
369
+ "same_ref": "{ table_id: \"tbl_orders\", filter: { node_type: \"group\", logic: \"and\", children: [ { field_key: \"fld_ref\", operator: \"equals\", value: record[\"fld_ref\"] }, { field_key: \"fld_closed_at\", operator: \"is_empty\" } ] }, limit: 2 }"
370
+ },
371
+ "fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",
372
+ "message": "\"An open order already has this reference.\"",
373
+ "field_key": "fld_ref"
374
+ }
375
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.289.0",
3
+ "version": "0.291.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {