@xanots/sdk 0.0.8 → 0.0.9

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/bin.js +4 -4
  4. package/dist/{chunk-2ROR3AZC.js → chunk-5YQOIFT4.js} +2 -2
  5. package/dist/{chunk-C3M5K4ZH.js → chunk-6Z5CNWFC.js} +2 -2
  6. package/dist/{chunk-7AXQKABT.js → chunk-7OXFMCTX.js} +3 -3
  7. package/dist/{chunk-SNO3KHCY.js → chunk-K3YSQDXJ.js} +32 -18
  8. package/dist/{chunk-PYS7UNNW.js → chunk-KSV7VOEX.js} +2 -2
  9. package/dist/{chunk-EM5QPTXP.js → chunk-L6TPHWAU.js} +7 -7
  10. package/dist/{chunk-CDAQUY5W.js → chunk-OW2QZEWL.js} +2 -2
  11. package/dist/{chunk-64QK6JEK.js → chunk-QONOJO45.js} +36 -2
  12. package/dist/{chunk-FEQTQ6PM.js → chunk-UDL46O35.js} +2 -2
  13. package/dist/{chunk-XP7S3VWY.js → chunk-YNZFIY4J.js} +2 -2
  14. package/dist/{chunk-3MDKV2VA.js → chunk-ZZLPTHT5.js} +2 -2
  15. package/dist/cli.js +3 -3
  16. package/dist/{codegen-command-U2W72DLG.js → codegen-command-YSCSCVT5.js} +8 -8
  17. package/dist/{deploy-command-QZ3GNLAD.js → deploy-command-5TTI4TUP.js} +9 -9
  18. package/dist/{env-target-NAYVEXBH.js → env-target-4T7AT347.js} +2 -2
  19. package/dist/{ephemeral-command-DL7EY4LL.js → ephemeral-command-7TK52YQM.js} +2 -2
  20. package/dist/index.js +5 -5
  21. package/dist/{init-command-4MG3UKUO.js → init-command-OVFFW4QS.js} +5 -5
  22. package/dist/internal.js +18 -12
  23. package/dist/{lock-commands-Y4S4NUCE.js → lock-commands-ET7NIOLH.js} +4 -4
  24. package/dist/node.js +6 -6
  25. package/dist/{release-command-DN75G5GJ.js → release-command-4EXVLYV2.js} +7 -7
  26. package/dist/{test-command-RRNNJ45I.js → test-command-2F5LKMI6.js} +3 -3
  27. package/dist/{upgrade-command-XFOAWDI3.js → upgrade-command-QL523I62.js} +5 -5
  28. package/dist/{validate-command-GLF5ZOOW.js → validate-command-SK5FPCV7.js} +5 -5
  29. package/dist/{workspace-command-QCFELEGR.js → workspace-command-HYA6JEKK.js} +8 -8
  30. package/guides/authoring.md +9 -1
  31. package/llms/fields.md +3 -2
  32. package/llms/statements-calls.md +2 -1
  33. package/llms/statements-data.md +5 -2
  34. package/llms/tests.md +2 -1
  35. package/llms-full.txt +17 -11
  36. package/llms.txt +5 -5
  37. package/manifest.json +1 -1
  38. package/package.json +1 -1
@@ -2,7 +2,7 @@ import {
2
2
  projectShellFiles,
3
3
  runInitCommand,
4
4
  sanitizeAppName
5
- } from "./chunk-C3M5K4ZH.js";
5
+ } from "./chunk-6Z5CNWFC.js";
6
6
  import {
7
7
  resolveAiFlags
8
8
  } from "./chunk-4PE4ZAB7.js";
@@ -10,14 +10,14 @@ import "./chunk-ZSYZTGJH.js";
10
10
  import "./chunk-MU3O43L2.js";
11
11
  import "./chunk-P5C2YKAM.js";
12
12
  import "./chunk-Q77KNEUL.js";
13
- import "./chunk-SNO3KHCY.js";
13
+ import "./chunk-K3YSQDXJ.js";
14
14
  import "./chunk-JQLR64UC.js";
15
15
  import "./chunk-OYMR5AMJ.js";
16
16
  import "./chunk-GNPVYOPB.js";
17
17
  import "./chunk-EZG76F7R.js";
18
- import "./chunk-FEQTQ6PM.js";
18
+ import "./chunk-UDL46O35.js";
19
19
  import "./chunk-OKNSR7MT.js";
20
- import "./chunk-64QK6JEK.js";
20
+ import "./chunk-QONOJO45.js";
21
21
  import "./chunk-WHOJWOSV.js";
22
22
  import "./chunk-5XZ744TS.js";
23
23
  import "./chunk-7JDT4PBU.js";
@@ -27,4 +27,4 @@ export {
27
27
  runInitCommand,
28
28
  sanitizeAppName
29
29
  };
30
- //# sourceMappingURL=init-command-4MG3UKUO.js.map
30
+ //# sourceMappingURL=init-command-OVFFW4QS.js.map
package/dist/internal.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  taskKind,
34
34
  toolKind,
35
35
  workflowTestKind
36
- } from "./chunk-7AXQKABT.js";
36
+ } from "./chunk-7OXFMCTX.js";
37
37
  import {
38
38
  GENERATED_SPECS,
39
39
  GENERATED_STATEMENT_NAMES,
@@ -55,7 +55,7 @@ import {
55
55
  registerSpec,
56
56
  triggerKind,
57
57
  workspaceKind
58
- } from "./chunk-3MDKV2VA.js";
58
+ } from "./chunk-ZZLPTHT5.js";
59
59
  import {
60
60
  COMMANDS,
61
61
  FLAGS,
@@ -109,7 +109,7 @@ import {
109
109
  serializeBundle,
110
110
  tableKind,
111
111
  toNestedFields
112
- } from "./chunk-64QK6JEK.js";
112
+ } from "./chunk-QONOJO45.js";
113
113
  import "./chunk-WHOJWOSV.js";
114
114
  import {
115
115
  encodeStatement,
@@ -765,7 +765,7 @@ function renderTestsSection() {
765
765
  "",
766
766
  "- The run uses an EMPTY datasource, so **no `table({ seed })` rows exist while it runs** and every `db` read misses. A test that buys seeded row 1 fails with its own precondition message, which reads as a wrong id rather than an empty database. Create what the test needs INSIDE the test \u2014 typically a `defineFunction` fixture the stack calls first.",
767
767
  '- `s.api.call` does NOT raise when the endpoint answers with an error. It BINDS the error envelope (`{code, message}`) to its `as` and carries on, so a later `s.expect.to_be_defined({ expr: ref("r.field") })` reports the ASSERTION while the real failure was the call, four statements up. Assert on the envelope \u2014 `s.expect.to_contain({ expr: ref("r.code"), value: c.text("ERROR_CODE_INPUT_ERROR") })` \u2014 when a call may fail. `s.function.run` raises instead; the two disagree.',
768
- "- `s.expect.to_throw({ body, exception? })` runs `body` in an ISOLATED var stack, so a variable bound EARLIER in the test is not visible inside it \u2014 bind what the body needs inside the body. `exception` is text the raised message must CONTAIN; omit it to accept any error.",
768
+ '- `s.expect.to_throw({ body, exception? })` runs `body` in an ISOLATED var stack, so a variable bound EARLIER in the test is not visible inside it \u2014 bind what the body needs inside the body. `exception` is a `Value` whose text the raised message must CONTAIN (`c.text("already exists")`, not a bare string); omit it to accept any error.',
769
769
  "- `s.expect.to_throw` catches such a call only when the error carries a MESSAGE. `ERROR_CODE_ACCESS_DENIED` arrives with an empty one, so `to_throw` around an auth-refused call reports `to_throw failed - response is ok` \u2014 which reads as a broken auth gate on a gate that works.",
770
770
  "- An endpoint's `auth` gate is NOT enforced on `s.api.call`. A `query({ auth: users })` runs anyway and fails only where its stack dereferences `auth(...)`. A stack that never touches `auth(...)` runs unauthenticated and passes.",
771
771
  "- Neither `auth.token` nor an `Authorization` entry in `headers` authenticates the call \u2014 a token that answers 200 over real HTTP is refused here. To cover auth-gated logic, move the body into a `defineFunction` taking the user id and `s.function.call` that; the gate itself is not reachable from a workflow test.",
@@ -778,6 +778,7 @@ function renderTestsSection() {
778
778
  '- `xanots test list` shows what is there without running it; `xanots test run "<name>"` runs one. When a name is ambiguous the error prints the qualified `function:math/happy path` form, which `run` also accepts.',
779
779
  "- `--kind unit|workflow` narrows to one family. `--concurrency <n>` defaults to 1: tests share the environment database.",
780
780
  "- A failing suite exits 5, distinct from a crash. Tests that could not be REACHED exit 6 \u2014 retry that one, investigate the other. An environment with no tests is success, not failure.",
781
+ '- For CI, the exit code says THAT something failed and the JSON says WHICH. Every progress line goes to stderr and stdout carries one JSON document \u2014 emitted whenever stdout is not a terminal, or on demand with `--json`. `run-all` and `run`: `{ dest, env, total, passed, failed, tests: [{ kind, name, object?, status: "pass"|"fail", message?, timing? }] }`, with the same keys on an empty suite. `list` is `{ dest, env, total, tests: [...] }` and `deploy --test` nests the run under `testRun`. The per-test array is always `tests`.',
781
782
  "- `xanots deploy ./index.ts --test` deploys and then runs the suite against what it just shipped. A failure exits 5 WITHOUT retracting the deploy \u2014 the environment is live either way.",
782
783
  ""
783
784
  ];
@@ -1125,7 +1126,7 @@ function renderSections(m) {
1125
1126
  " `s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`",
1126
1127
  " take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);",
1127
1128
  " `s.group(body)` and `s.util.post_process(body)` take it **positionally**.",
1128
- " `s.for` is **count-bounded** (`{ as, count, body }`), not from/to. See the",
1129
+ " `s.for` is **count-bounded** (`{ as, count: <Value>, body }`), not from/to. See the",
1129
1130
  " authored signatures in `llms/statements-data.md`.",
1130
1131
  "- **MCP servers & agents are distinct root kinds** that both persist under the",
1131
1132
  " `toolset` payload key (so a same-name pair collides). `mcpServer({...})` exposes",
@@ -1477,8 +1478,9 @@ function renderSections(m) {
1477
1478
  "`s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather",
1478
1479
  "than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for",
1479
1480
  '"not set yet" \u2014 `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and',
1480
- "binds `null`, which is the answer the null was reaching for. `export()` warns on a literal",
1481
- "`c.null()` in that slot.",
1481
+ "binds `null`, which is the answer the null was reaching for \u2014 and never `s.db.get_by_id`,",
1482
+ "which validates `id >= 1` and fails the whole request on the sentinel. `export()` warns on a",
1483
+ "literal `c.null()` in that slot.",
1482
1484
  "An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through",
1483
1485
  'any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:',
1484
1486
  '"vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see',
@@ -1630,14 +1632,14 @@ function renderSections(m) {
1630
1632
  '- **Every** statement takes `disabled?`/`description?` \u2014 annotations on the stack item, not args: `disabled: true` is Xano\'s "disable step" (kept in the stack, skipped at runtime), `description` the note beside it. Inline on object-arg factories; a trailing object on the positional ones (`s.set_var("x", v, { disabled: true })`).',
1631
1633
  '- **Statements with an `as`** also take `asFilters?` \u2014 `fl.*` filters on the RESULT as it binds, in order, same slot as `disabled`: `s.set_var("x", v, { asFilters: [fl.trim(), fl.lower()] })`. Saves a follow-up `set_var`. Throws without an `as`. The bound variable is RETYPED by the chain (`db.query` + `[fl.count()]` \u2192 `number`); filters whose result the engine declares as `any` (`get`, `set`, `json_decode`, \u2026) fold to `unknown`.',
1632
1634
  "- `s.conditional({ when, then, elif?, else? })` \u2014 if/elif/else. `when` is a condition (`expr`/`cmp`/`and`/`or`); `elif` is an ordered `[{ when, then }]` (each an else-if branch); `then`/`else` are `Statement[]`.",
1633
- "- `s.for({ as, count, body })` \u2014 **count-bounded** loop (`as` is the index), NOT from/to.",
1635
+ "- `s.for({ as, count, body })` \u2014 **count-bounded** loop (`as` is the index), NOT from/to; `count` is a `Value`, not a bare number.",
1634
1636
  "- `s.foreach({ as, list, body })` \u2014 iterate `list`; `as` is the current item.",
1635
1637
  "- `s.while({ when, body })` \u2014 `when` is a condition (`expr`/`cmp`/`and`/`or`).",
1636
1638
  "- `s.switch({ on, cases: [{ when, body, break? }], default? })` \u2014 multi-way branch on a subject `Value` `on`; each `case`'s `when` is a literal `Value` matched against `on` (NOT a comparison \u2014 use `s.conditional` for `<`/`>`/ranges). \u26A0 **Omitting `break: true` FALLS THROUGH** \u2014 the matched case also runs every LATER case body. Type-checks clean; only `export --strict` catches it.",
1637
1639
  "- `s.try_catch({ try, catch?, finally? })` \u2014 three `Statement[]` blocks.",
1638
1640
  "- `s.group(body)` / `s.util.post_process(body)` \u2014 take a `Statement[]` **positionally**.",
1639
1641
  "- `s.foreach_break()` / `s.foreach_continue()` / `s.foreach_remove()` \u2014 nullary loop control.",
1640
- "- `s.expect.to_throw({ body, exception? })` \u2014 `body` is the statements expected to raise.",
1642
+ "- `s.expect.to_throw({ body, exception? })` \u2014 `body` is the statements expected to raise; `exception` is a `Value`, not a bare string.",
1641
1643
  ""
1642
1644
  );
1643
1645
  const stmtData = [
@@ -1650,7 +1652,8 @@ function renderSections(m) {
1650
1652
  "primary key `id`):",
1651
1653
  "",
1652
1654
  "- `s.db.get({ table, fieldName?, fieldValue, lock?, output?, as? })` \u2014 one row by field match; `output` restricts returned columns (and overrides column visibility \u2014 it can pull `internal` columns like a password hash).",
1653
- "- `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` \u2014 get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` like `db.get`. Both spellings are live in pulled workspaces.",
1655
+ "- `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` \u2014 get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` for an id that names no row. Both spellings are live in pulled workspaces.",
1656
+ ' - \u26A0 `id` is validated `>= 1`, so the `0` sentinel an optional `f.tableRef` stores fails the request with HTTP 400 `Value is less than the minimum value of 1` \u2014 it does NOT bind `null`. The throw is not scoped to the lookup: inside a `foreach` it kills the whole request, so one unset FK loses every other row\'s work. Read a nullable FK with the field-match form, which binds `null` on `0` and lets the loop finish: `s.db.get({ table, fieldName: "id", fieldValue: ref("row.fk"), as })`.',
1654
1657
  "- `s.db.has({ table, fieldName?, fieldValue, as? })` \u2014 existence test.",
1655
1658
  "- `s.db.del({ table, fieldName?, fieldValue, as? })` \u2014 delete by field match.",
1656
1659
  "- `s.db.add({ table, row?, data?, output?, as? })` \u2014 insert; `row` is a partial keyed by column.",
@@ -1658,8 +1661,9 @@ function renderSections(m) {
1658
1661
  " - `null` is accepted on EVERY column, including ones that refuse every other literal, and encodes `const:null` \u2014 a write OF null, not the same as omitting the key. A column's `nullable` is not consulted at encode; the engine refuses a null it forbids.",
1659
1662
  " - Omitting a key on `add` writes the column's type default \u2014 declared `default` if set, `[]` for a list, `{}` for obj/json, else `null`. That `null` is EMITTED, not what the row holds: the engine applies the column's nullability, so a `nullable` column keeps `null` and a non-nullable one lands on its type's zero value (`\"\"`, `0`, `false`). Set the cell when the stored value matters. On `edit` an omitted key keeps its stored value.",
1660
1663
  " - An `f.password()` cell takes the PLAINTEXT \u2014 the column hashes on write, so a pre-hashed value, or a hashing filter on the cell, stores a hash of a hash that `security.check_password` can never match.",
1664
+ " - A `table({ seed })` cell hashes the same way: the import writes the plaintext through the column's own rules, so a seeded credential matches under `security.check_password` exactly as an added one does. Demo accounts work as fixtures \u2014 the usual caution about seed data applies, since the plaintext sits in the repo.",
1661
1665
  "- `s.db.edit({ table, fieldName?, fieldValue, row?, data?, output?, as? })` \u2014 update by field match.",
1662
- "- `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` \u2014 merge a partial (`data` is an object value).",
1666
+ "- `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` \u2014 merge a partial. \u26A0 Unlike `db.edit`'s `row`, `data` is a single object `Value` \u2014 write `obj({ unread: c.int(0) })`, not the column-keyed record `row` takes.",
1663
1667
  " On these three, `output` restricts the columns of the RETURNED row only \u2014 it does not change",
1664
1668
  " what is written. Not offered on `db.del`/`db.has` (their result is a scalar) or on",
1665
1669
  " `db.add_or_edit` (no output envelope).",
@@ -1668,6 +1672,7 @@ function renderSections(m) {
1668
1672
  " - `where` / `additionalWhere` \u2014 `expr(...)`, an `expr[]` (ANDed), or a raw `Value`. Rides `context.search`.",
1669
1673
  " - \u26A0 `ignoreEmpty` DROPS the predicate when the operand is empty \u2014 it does not match zero rows. On an `in` comparison an empty list therefore returns the UNFILTERED set, so never use it to scope rows to a permitted-id list: an empty list of permissions returns everything.",
1670
1674
  " - For the full operator set use `cmp(left, op, right, { ignoreEmpty? })` \u2014 `op`: `in`/`not in`/`like`/`ilike`/`between`/`contains`/`includes`/`overlaps`/`@>`/`~`/`search`/\u2026 plus the `expr` comparisons. Database-only \u2014 a runtime condition takes the `expr` set only.",
1675
+ ' - \u26A0 `like`/`ilike` take the operand as the PATTERN, verbatim: a bare term matches only an exact whole-string equal, and the endpoint answers HTTP 200 with zero rows \u2014 nothing reports a problem, so a search box that matches nothing ships. For substring matching use `includes`/`not includes`, which wrap the operand in `%\u2026%` themselves and match case-INSENSITIVELY. Prefer them over a hand-built `"%" + term + "%"`, which is non-empty even for an empty term and so defeats `ignoreEmpty`; `includes` composes with it. `contains`/`@>`/`overlaps` are JSON/array containment, not text \u2014 on a text column they 400 `ParseError: Invalid value for param`.',
1671
1676
  " - Compose nested boolean logic with `and(...)` / `or(...)` groups (also available on `addon()` `where`).",
1672
1677
  " - An operand may be a bare value (`col`/`inp`/`ref`/`auth`/`c.*`) OR a **filtered** value (`withFilters(...)`) inline \u2014 the engine compiles the string and arithmetic filters (`trim`, `concat`, `upper`, `lower`, \u2026) into the SQL.",
1673
1678
  " - \u26A0 The REQUEST-TIME timestamp filters have no SQL form and kill the request with a bare fatal naming nothing: `epochms_transform`, `epochms_add_ms`, `epochms_add_secs`, `epochms_date`, `epochms_from_format`. For a relative cutoff use the SQL-side family instead (`epochms_add_day`, `epochms_sub_month`, `epochms_year`, \u2026) or compute it in an earlier `s.set_var` and `ref()` that. `export --strict` reports it; a bare `c.now()` operand is always fine.",
@@ -1793,6 +1798,7 @@ function renderSections(m) {
1793
1798
  '- `s.api.call({ api, input?, headers?, auth?, as? })` \u2014 invoke an endpoint. `api` takes the `query()` def HANDLE (or a `{ name, guid }` pair) \u2014 a bare name is refused, because a query\'s identity is composed from its api group, verb, and name. `headers` REPLACES the request headers the callee sees and takes the same shapes as `s.api.request`\'s \u2014 a `{ "Name": value }` record (values may be tagged), a `string[]` of `"Name: value"` lines, or one `Value`. `auth` is `{ token, ignoreExpiration? }`: `token` must be a BARE STRING \u2014 a tagged `Value` deploys clean and then fails the run with `Param: token - Text filter requires an integer, float, string or boolean value`, since the engine stores that slot as plain text and never evaluates it. Neither slot authenticates the call today; see `llms/tests.md` for what a workflow-test run actually sees. WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`) \u2014 elsewhere it deploys clean and 500s the first real request.',
1794
1799
  '- `s.api.request({ url, method?, params?, headers?, timeout?, follow_location?, verify_host?, verify_peer?, ca_certificate?, certificate?, certificate_pass?, private_key?, private_key_pass?, description?, output?, as? })` \u2014 external HTTP request (`mvp:api_request`). Ergonomic types, each also accepting a dynamic `Value`: `method` suggests the 7 verbs (GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH), `params` a plain JSON object **or** a FLAT record whose values are tagged `Value`s (`{ count: ref("count") }`, each lifted via a `set` filter \u2014 the same record-of-values shape `response: { key: value }` takes); a tagged value NESTED inside an object or array THROWS at encode, so wrap a structured body in `obj({...})`, which encodes any depth as one `const:expr2` (\u2192 query string for GET/HEAD/OPTIONS, body otherwise), `headers` a `{ "Name": value }` record whose values may be tagged (`{ "x-api-key": env("KEY") }`, each pair joined to a `"Name: value"` line) **or** a `string[]` of full header lines \u2014 prefer a header over a `?key=` query param for a credential \u2014 a URL travels into access logs, proxies and `Referer`. \u26A0 Neither spelling is envelope-safe: the `as` envelope\'s `request` half mirrors `url`, `params` AND `headers`, so never return it raw from a credentialed request \u2014 read `response.result`. A NAME outside the header-token charset is refused, and a LITERAL value carrying a newline is refused; a value may hold `:` and spaces (`Bearer a: b` is a valid value). A TAGGED value cannot be checked at build time, so strip CRLF from caller-controlled input before this slot. Literal pairs lead the emitted array and computed ones follow, so the wire order is not the record\'s key order, `timeout` a `number` in seconds (1\u201386400), and `follow_location`/`verify_host`/`verify_peer` booleans. `description` (Settings tab) and `output` filters (Output tab) ride the envelope. SSL cert interdependencies (certificate\u2194private_key, ca_certificate\u2192verify_peer) are checked at build time when statically provable, else by the engine at runtime. The `as` result is typed as the `{request, response}` envelope (`response.status: number`, `response.result: unknown`), so `InferResponse` resolves a `ref` to it. Same typed result on `webflow.request` and `microservice.request`.',
1795
1800
  "- `s.stream.from_request({ url, method?, \u2026tls, as? })` \u2014 streaming external HTTP request (`mvp:streaming_api_request`); same typed field surface as `s.api.request` (no description/output envelope). `url` is REQUIRED \u2014 it shares `api.request`'s engine declaration, which has no default.",
1801
+ '- `s.util.send_email({ to, subject, message, from?, cc?, bcc?, reply_to?, service_provider?, api_key?, scheduled_at?, as? })` \u2014 send email from the stack. `service_provider` is `"xano"` (the built-in mailer \u2014 needs NO `api_key` and no configuration, and does not require a verified sender) or `"resend"` (pass the key as `api_key: env("RESEND_API_KEY")`). Prefer this over hand-rolling `s.api.request` against a mail provider.',
1796
1802
  "- `s.webflow.request({ path, method?, \u2026tls, as? })` \u2014 Webflow API request (`mvp:connect_webflow_api_request`); like `s.api.request` but addressed by `path` (host is engine-supplied), and `path` is REQUIRED \u2014 the engine rejects an empty one. No `headers`: the engine builds its own from the workspace's Webflow connection and ignores an authored value.",
1797
1803
  "- `s.task.call` / `s.tool.call` / `s.trigger.call` / `s.middleware.call` / `s.addon.call` \u2014 same `{ <target>, input?, as? }` shape against the named kind. `s.task.call` and `s.trigger.call` are WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`); `s.tool.call`, `s.middleware.call` and `s.addon.call` run from any stack.",
1798
1804
  "- `s.action.call({ actionId, input?, registry?, as? })` / `s.action.package.call({ traceId, versionId, slug, input?, registry?, as? })` \u2014 invoke an installed action. Ids are SUPPLIED, never derived from a name: an action is installed onto the instance, so its identity is assigned at install and differs per instance \u2014 read them off a call in a pulled workspace. The package form needs all three parts; they are one composite and two of them address nothing. `registry` is the action's own settings, `input` the per-call arguments.",
@@ -1991,7 +1997,7 @@ function renderDocs(m) {
1991
1997
  {
1992
1998
  path: "llms/statements-calls.md",
1993
1999
  title: "Auth, cross-object calls, and microservices",
1994
- readWhen: "Read when the stack authenticates (the signup/login/token recipe is here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), or reaches a microservice.",
2000
+ readWhen: "Read when the stack authenticates (the signup/login/token recipe is here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), sends email (`s.util.send_email`), or reaches a microservice.",
1995
2001
  body: s.stmtCalls
1996
2002
  },
1997
2003
  {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  loadDefault
3
- } from "./chunk-SNO3KHCY.js";
3
+ } from "./chunk-K3YSQDXJ.js";
4
4
  import {
5
5
  UsageError,
6
6
  missingArgument,
@@ -14,9 +14,9 @@ import "./chunk-GNPVYOPB.js";
14
14
  import "./chunk-EZG76F7R.js";
15
15
  import {
16
16
  Xano
17
- } from "./chunk-FEQTQ6PM.js";
17
+ } from "./chunk-UDL46O35.js";
18
18
  import "./chunk-OKNSR7MT.js";
19
- import "./chunk-64QK6JEK.js";
19
+ import "./chunk-QONOJO45.js";
20
20
  import "./chunk-WHOJWOSV.js";
21
21
  import "./chunk-5XZ744TS.js";
22
22
  import {
@@ -229,4 +229,4 @@ ${lines}
229
229
  export {
230
230
  runLockCommand
231
231
  };
232
- //# sourceMappingURL=lock-commands-Y4S4NUCE.js.map
232
+ //# sourceMappingURL=lock-commands-ET7NIOLH.js.map
package/dist/node.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  TEST_EXPECT_TYPES,
7
7
  expect
8
- } from "./chunk-2ROR3AZC.js";
8
+ } from "./chunk-5YQOIFT4.js";
9
9
  import {
10
10
  addon,
11
11
  agent,
@@ -17,7 +17,7 @@ import {
17
17
  task,
18
18
  tool,
19
19
  workflowTest
20
- } from "./chunk-7AXQKABT.js";
20
+ } from "./chunk-7OXFMCTX.js";
21
21
  import {
22
22
  actionCall,
23
23
  actionPackageCall,
@@ -100,7 +100,7 @@ import {
100
100
  workflowTestCall,
101
101
  workspaceConfig,
102
102
  workspaceTrigger
103
- } from "./chunk-3MDKV2VA.js";
103
+ } from "./chunk-ZZLPTHT5.js";
104
104
  import {
105
105
  exportBundleJson,
106
106
  loadDefault,
@@ -108,7 +108,7 @@ import {
108
108
  run,
109
109
  writeArtifact,
110
110
  writeBundle
111
- } from "./chunk-SNO3KHCY.js";
111
+ } from "./chunk-K3YSQDXJ.js";
112
112
  import "./chunk-JQLR64UC.js";
113
113
  import {
114
114
  readLockFile,
@@ -119,7 +119,7 @@ import "./chunk-EZG76F7R.js";
119
119
  import {
120
120
  Xano,
121
121
  workspace
122
- } from "./chunk-FEQTQ6PM.js";
122
+ } from "./chunk-UDL46O35.js";
123
123
  import "./chunk-OKNSR7MT.js";
124
124
  import {
125
125
  LAMBDA_BINDINGS,
@@ -167,7 +167,7 @@ import {
167
167
  toSearchParams,
168
168
  toolset,
169
169
  withFilters
170
- } from "./chunk-64QK6JEK.js";
170
+ } from "./chunk-QONOJO45.js";
171
171
  import "./chunk-WHOJWOSV.js";
172
172
  import {
173
173
  annotate,
@@ -6,7 +6,7 @@ import {
6
6
  import {
7
7
  assertBundleInput,
8
8
  loadBundleText
9
- } from "./chunk-PYS7UNNW.js";
9
+ } from "./chunk-KSV7VOEX.js";
10
10
  import {
11
11
  sharedSchemaChanges
12
12
  } from "./chunk-5HCDZ2XN.js";
@@ -37,7 +37,7 @@ import {
37
37
  import "./chunk-3IGNIP6R.js";
38
38
  import {
39
39
  resolveLockPath
40
- } from "./chunk-SNO3KHCY.js";
40
+ } from "./chunk-K3YSQDXJ.js";
41
41
  import {
42
42
  UsageError,
43
43
  assertWritableDest
@@ -55,9 +55,9 @@ import {
55
55
  success,
56
56
  warn
57
57
  } from "./chunk-EZG76F7R.js";
58
- import "./chunk-FEQTQ6PM.js";
58
+ import "./chunk-UDL46O35.js";
59
59
  import "./chunk-OKNSR7MT.js";
60
- import "./chunk-64QK6JEK.js";
60
+ import "./chunk-QONOJO45.js";
61
61
  import "./chunk-WHOJWOSV.js";
62
62
  import "./chunk-5XZ744TS.js";
63
63
  import {
@@ -443,7 +443,7 @@ async function runReleaseCommand(args) {
443
443
  { withSeed: req.records === true }
444
444
  );
445
445
  if (args.static !== void 0) {
446
- const { assertNoSeedLeaks } = await import("./deploy-command-QZ3GNLAD.js");
446
+ const { assertNoSeedLeaks } = await import("./deploy-command-5TTI4TUP.js");
447
447
  await assertNoSeedLeaks(args.static, nonPublicSeedValues, args.allowSeedInStatic);
448
448
  }
449
449
  const workspaceId = auth.workspaceId;
@@ -585,7 +585,7 @@ Retry, or drop \`--backup-branch\` to release without one.`,
585
585
  ...backupLabel !== void 0 ? { backupBranch: backupLabel } : {}
586
586
  };
587
587
  if (args.static !== void 0) {
588
- const { buildStaticEnv, deployStaticTo } = await import("./deploy-command-QZ3GNLAD.js");
588
+ const { buildStaticEnv, deployStaticTo } = await import("./deploy-command-5TTI4TUP.js");
589
589
  const env = buildStaticEnv(auth.instance, args.staticEnv);
590
590
  const explicit = Object.keys(args.staticEnv).length > 0;
591
591
  summary.static = await deployStaticTo(
@@ -604,4 +604,4 @@ Retry, or drop \`--backup-branch\` to release without one.`,
604
604
  export {
605
605
  runReleaseCommand
606
606
  };
607
- //# sourceMappingURL=release-command-DN75G5GJ.js.map
607
+ //# sourceMappingURL=release-command-4EXVLYV2.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveMetaTarget
3
- } from "./chunk-XP7S3VWY.js";
3
+ } from "./chunk-YNZFIY4J.js";
4
4
  import "./chunk-MDXR5E5Q.js";
5
5
  import "./chunk-3EYUR3TX.js";
6
6
  import "./chunk-NDZFBZHC.js";
@@ -224,7 +224,7 @@ function summarize(dest, env, outcomes) {
224
224
  total: outcomes.length,
225
225
  passed: outcomes.filter((o) => o.status === "pass").length,
226
226
  failed: outcomes.filter((o) => o.status === "fail").length,
227
- results: outcomes.map((o) => ({
227
+ tests: outcomes.map((o) => ({
228
228
  kind: o.kind,
229
229
  name: o.name,
230
230
  object: o.object,
@@ -380,4 +380,4 @@ export {
380
380
  runSuite,
381
381
  runTestCommand
382
382
  };
383
- //# sourceMappingURL=test-command-RRNNJ45I.js.map
383
+ //# sourceMappingURL=test-command-2F5LKMI6.js.map
@@ -22,8 +22,8 @@ import {
22
22
  registryUrl,
23
23
  suppressUpdateNotice,
24
24
  upgradeCommand
25
- } from "./chunk-CDAQUY5W.js";
26
- import "./chunk-SNO3KHCY.js";
25
+ } from "./chunk-OW2QZEWL.js";
26
+ import "./chunk-K3YSQDXJ.js";
27
27
  import "./chunk-JQLR64UC.js";
28
28
  import "./chunk-OYMR5AMJ.js";
29
29
  import "./chunk-GNPVYOPB.js";
@@ -35,9 +35,9 @@ import {
35
35
  success,
36
36
  warn
37
37
  } from "./chunk-EZG76F7R.js";
38
- import "./chunk-FEQTQ6PM.js";
38
+ import "./chunk-UDL46O35.js";
39
39
  import "./chunk-OKNSR7MT.js";
40
- import "./chunk-64QK6JEK.js";
40
+ import "./chunk-QONOJO45.js";
41
41
  import "./chunk-WHOJWOSV.js";
42
42
  import "./chunk-5XZ744TS.js";
43
43
  import "./chunk-7JDT4PBU.js";
@@ -174,4 +174,4 @@ function messageOf(err) {
174
174
  export {
175
175
  runUpgradeCommand
176
176
  };
177
- //# sourceMappingURL=upgrade-command-XFOAWDI3.js.map
177
+ //# sourceMappingURL=upgrade-command-QL523I62.js.map
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  assertBundleInput,
3
3
  loadBundleText
4
- } from "./chunk-PYS7UNNW.js";
5
- import "./chunk-SNO3KHCY.js";
4
+ } from "./chunk-KSV7VOEX.js";
5
+ import "./chunk-K3YSQDXJ.js";
6
6
  import "./chunk-JQLR64UC.js";
7
7
  import "./chunk-OYMR5AMJ.js";
8
8
  import "./chunk-GNPVYOPB.js";
@@ -14,9 +14,9 @@ import {
14
14
  success,
15
15
  warn
16
16
  } from "./chunk-EZG76F7R.js";
17
- import "./chunk-FEQTQ6PM.js";
17
+ import "./chunk-UDL46O35.js";
18
18
  import "./chunk-OKNSR7MT.js";
19
- import "./chunk-64QK6JEK.js";
19
+ import "./chunk-QONOJO45.js";
20
20
  import "./chunk-WHOJWOSV.js";
21
21
  import "./chunk-5XZ744TS.js";
22
22
  import "./chunk-7JDT4PBU.js";
@@ -119,4 +119,4 @@ function fmt(v) {
119
119
  export {
120
120
  runValidateCommand
121
121
  };
122
- //# sourceMappingURL=validate-command-GLF5ZOOW.js.map
122
+ //# sourceMappingURL=validate-command-SK5FPCV7.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  fetchWorkspaceBundle,
3
3
  runCodegenCommand
4
- } from "./chunk-EM5QPTXP.js";
4
+ } from "./chunk-L6TPHWAU.js";
5
5
  import "./chunk-ZOYMZZ3S.js";
6
6
  import {
7
7
  resolveOutputTarget
@@ -19,7 +19,7 @@ import "./chunk-BKDJN76G.js";
19
19
  import "./chunk-QTNO2WD6.js";
20
20
  import "./chunk-ZUTSMMAG.js";
21
21
  import "./chunk-X4DVXBFY.js";
22
- import "./chunk-C3M5K4ZH.js";
22
+ import "./chunk-6Z5CNWFC.js";
23
23
  import {
24
24
  isMachineOutput,
25
25
  writeJson
@@ -30,14 +30,14 @@ import {
30
30
  fetchOrExplain,
31
31
  httpFailure
32
32
  } from "./chunk-3IGNIP6R.js";
33
- import "./chunk-2ROR3AZC.js";
33
+ import "./chunk-5YQOIFT4.js";
34
34
  import "./chunk-LU7TRWMC.js";
35
35
  import "./chunk-MU3O43L2.js";
36
36
  import "./chunk-P5C2YKAM.js";
37
37
  import "./chunk-Q77KNEUL.js";
38
38
  import "./chunk-4BXJGVZ3.js";
39
- import "./chunk-3MDKV2VA.js";
40
- import "./chunk-SNO3KHCY.js";
39
+ import "./chunk-ZZLPTHT5.js";
40
+ import "./chunk-K3YSQDXJ.js";
41
41
  import {
42
42
  removedSubcommand,
43
43
  unknownSubcommand
@@ -50,9 +50,9 @@ import {
50
50
  step,
51
51
  success
52
52
  } from "./chunk-EZG76F7R.js";
53
- import "./chunk-FEQTQ6PM.js";
53
+ import "./chunk-UDL46O35.js";
54
54
  import "./chunk-OKNSR7MT.js";
55
- import "./chunk-64QK6JEK.js";
55
+ import "./chunk-QONOJO45.js";
56
56
  import "./chunk-WHOJWOSV.js";
57
57
  import "./chunk-5XZ744TS.js";
58
58
  import "./chunk-7JDT4PBU.js";
@@ -160,4 +160,4 @@ async function runExport(args) {
160
160
  export {
161
161
  runWorkspaceCommand
162
162
  };
163
- //# sourceMappingURL=workspace-command-QCFELEGR.js.map
163
+ //# sourceMappingURL=workspace-command-HYA6JEKK.js.map
@@ -127,7 +127,7 @@ auto-assigned `id`/`created_at`, `s.db.del` binds `null`, and `edit`/`del` **thr
127
127
  `s.db.query` mirrors the whole Xano query builder — `returnType`, `bind` joins, computed
128
128
  `eval` columns, `aggregate` groups, `distinct`, and the full operator set via
129
129
  `cmp(left, op, right)` with `and(...)`/`or(...)` for boolean groups. Signatures are in
130
- `llms/statements-data.md`; four behaviors are worth knowing here:
130
+ `llms/statements-data.md`; five behaviors are worth knowing here:
131
131
 
132
132
  - **A join condition spells its two sides differently.** The joined table's column takes its
133
133
  `as` alias; this query's own columns stay bare:
@@ -148,6 +148,14 @@ auto-assigned `id`/`created_at`, `s.db.del` binds `null`, and `edit`/`del` **thr
148
148
  disagree: a branch folds terms strictly left to right (`a OR b AND c` = `(a OR b) AND c`)
149
149
  while a `db.query` filter inherits SQL's AND-before-OR precedence (`a OR (b AND c)`). Write
150
150
  `and(or(a, b), c)` or `or(a, and(b, c))` — each says exactly one thing in every context.
151
+ - **`like`/`ilike` take the operand as the pattern, verbatim.** The operand *is* the pattern,
152
+ so `cmp(col("body"), "ilike", inp("q"))` with a bare term matches only a whole-string equal —
153
+ and the endpoint answers 200 with an empty list, which reads as "no results for that query".
154
+ For substring search use `includes` / `not includes`, which wrap the operand in `%…%`
155
+ themselves and match case-insensitively. Prefer that over building `"%" + term + "%"`: a
156
+ hand-built pattern is non-empty even for a blank term, which defeats the `ignoreEmpty` below.
157
+ `contains` / `@>` / `overlaps` are JSON and array containment, not text — on a text column
158
+ they fail with a parse error.
151
159
  - **`ignoreEmpty` DROPS the clause — it does not match zero rows.** `cmp(col("owner"), "in",
152
160
  ids, { ignoreEmpty: true })` with an empty `ids` returns the **unfiltered** table, where the
153
161
  same clause without the flag matches nothing. Never put it on a filter that scopes rows to a
package/llms/fields.md CHANGED
@@ -35,8 +35,9 @@ an `int`, and a null in it is unqueryable: `null` is never a legal `fieldValue`/
35
35
  `s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather
36
36
  than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for
37
37
  "not set yet" — `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and
38
- binds `null`, which is the answer the null was reaching for. `export()` warns on a literal
39
- `c.null()` in that slot.
38
+ binds `null`, which is the answer the null was reaching for and never `s.db.get_by_id`,
39
+ which validates `id >= 1` and fails the whole request on the sentinel. `export()` warns on a
40
+ literal `c.null()` in that slot.
40
41
  An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through
41
42
  any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:
42
43
  "vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see
@@ -1,6 +1,6 @@
1
1
  # Auth, cross-object calls, and microservices
2
2
 
3
- > Read when the stack authenticates (the signup/login/token recipe is here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), or reaches a microservice.
3
+ > Read when the stack authenticates (the signup/login/token recipe is here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), sends email (`s.util.send_email`), or reaches a microservice.
4
4
 
5
5
  Auth & calls:
6
6
 
@@ -16,6 +16,7 @@ Auth & calls:
16
16
  - `s.api.call({ api, input?, headers?, auth?, as? })` — invoke an endpoint. `api` takes the `query()` def HANDLE (or a `{ name, guid }` pair) — a bare name is refused, because a query's identity is composed from its api group, verb, and name. `headers` REPLACES the request headers the callee sees and takes the same shapes as `s.api.request`'s — a `{ "Name": value }` record (values may be tagged), a `string[]` of `"Name: value"` lines, or one `Value`. `auth` is `{ token, ignoreExpiration? }`: `token` must be a BARE STRING — a tagged `Value` deploys clean and then fails the run with `Param: token - Text filter requires an integer, float, string or boolean value`, since the engine stores that slot as plain text and never evaluates it. Neither slot authenticates the call today; see `llms/tests.md` for what a workflow-test run actually sees. WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`) — elsewhere it deploys clean and 500s the first real request.
17
17
  - `s.api.request({ url, method?, params?, headers?, timeout?, follow_location?, verify_host?, verify_peer?, ca_certificate?, certificate?, certificate_pass?, private_key?, private_key_pass?, description?, output?, as? })` — external HTTP request (`mvp:api_request`). Ergonomic types, each also accepting a dynamic `Value`: `method` suggests the 7 verbs (GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH), `params` a plain JSON object **or** a FLAT record whose values are tagged `Value`s (`{ count: ref("count") }`, each lifted via a `set` filter — the same record-of-values shape `response: { key: value }` takes); a tagged value NESTED inside an object or array THROWS at encode, so wrap a structured body in `obj({...})`, which encodes any depth as one `const:expr2` (→ query string for GET/HEAD/OPTIONS, body otherwise), `headers` a `{ "Name": value }` record whose values may be tagged (`{ "x-api-key": env("KEY") }`, each pair joined to a `"Name: value"` line) **or** a `string[]` of full header lines — prefer a header over a `?key=` query param for a credential — a URL travels into access logs, proxies and `Referer`. ⚠ Neither spelling is envelope-safe: the `as` envelope's `request` half mirrors `url`, `params` AND `headers`, so never return it raw from a credentialed request — read `response.result`. A NAME outside the header-token charset is refused, and a LITERAL value carrying a newline is refused; a value may hold `:` and spaces (`Bearer a: b` is a valid value). A TAGGED value cannot be checked at build time, so strip CRLF from caller-controlled input before this slot. Literal pairs lead the emitted array and computed ones follow, so the wire order is not the record's key order, `timeout` a `number` in seconds (1–86400), and `follow_location`/`verify_host`/`verify_peer` booleans. `description` (Settings tab) and `output` filters (Output tab) ride the envelope. SSL cert interdependencies (certificate↔private_key, ca_certificate→verify_peer) are checked at build time when statically provable, else by the engine at runtime. The `as` result is typed as the `{request, response}` envelope (`response.status: number`, `response.result: unknown`), so `InferResponse` resolves a `ref` to it. Same typed result on `webflow.request` and `microservice.request`.
18
18
  - `s.stream.from_request({ url, method?, …tls, as? })` — streaming external HTTP request (`mvp:streaming_api_request`); same typed field surface as `s.api.request` (no description/output envelope). `url` is REQUIRED — it shares `api.request`'s engine declaration, which has no default.
19
+ - `s.util.send_email({ to, subject, message, from?, cc?, bcc?, reply_to?, service_provider?, api_key?, scheduled_at?, as? })` — send email from the stack. `service_provider` is `"xano"` (the built-in mailer — needs NO `api_key` and no configuration, and does not require a verified sender) or `"resend"` (pass the key as `api_key: env("RESEND_API_KEY")`). Prefer this over hand-rolling `s.api.request` against a mail provider.
19
20
  - `s.webflow.request({ path, method?, …tls, as? })` — Webflow API request (`mvp:connect_webflow_api_request`); like `s.api.request` but addressed by `path` (host is engine-supplied), and `path` is REQUIRED — the engine rejects an empty one. No `headers`: the engine builds its own from the workspace's Webflow connection and ignores an authored value.
20
21
  - `s.task.call` / `s.tool.call` / `s.trigger.call` / `s.middleware.call` / `s.addon.call` — same `{ <target>, input?, as? }` shape against the named kind. `s.task.call` and `s.trigger.call` are WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`); `s.tool.call`, `s.middleware.call` and `s.addon.call` run from any stack.
21
22
  - `s.action.call({ actionId, input?, registry?, as? })` / `s.action.package.call({ traceId, versionId, slug, input?, registry?, as? })` — invoke an installed action. Ids are SUPPLIED, never derived from a name: an action is installed onto the instance, so its identity is assigned at install and differs per instance — read them off a call in a pulled workspace. The package form needs all three parts; they are one composite and two of them address nothing. `registry` is the action's own settings, `input` the per-call arguments.
@@ -11,7 +11,8 @@ DB reads/writes (`table` is a def handle or name; `fieldName` defaults to the
11
11
  primary key `id`):
12
12
 
13
13
  - `s.db.get({ table, fieldName?, fieldValue, lock?, output?, as? })` — one row by field match; `output` restricts returned columns (and overrides column visibility — it can pull `internal` columns like a password hash).
14
- - `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` — get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` like `db.get`. Both spellings are live in pulled workspaces.
14
+ - `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` — get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` for an id that names no row. Both spellings are live in pulled workspaces.
15
+ - ⚠ `id` is validated `>= 1`, so the `0` sentinel an optional `f.tableRef` stores fails the request with HTTP 400 `Value is less than the minimum value of 1` — it does NOT bind `null`. The throw is not scoped to the lookup: inside a `foreach` it kills the whole request, so one unset FK loses every other row's work. Read a nullable FK with the field-match form, which binds `null` on `0` and lets the loop finish: `s.db.get({ table, fieldName: "id", fieldValue: ref("row.fk"), as })`.
15
16
  - `s.db.has({ table, fieldName?, fieldValue, as? })` — existence test.
16
17
  - `s.db.del({ table, fieldName?, fieldValue, as? })` — delete by field match.
17
18
  - `s.db.add({ table, row?, data?, output?, as? })` — insert; `row` is a partial keyed by column.
@@ -19,8 +20,9 @@ primary key `id`):
19
20
  - `null` is accepted on EVERY column, including ones that refuse every other literal, and encodes `const:null` — a write OF null, not the same as omitting the key. A column's `nullable` is not consulted at encode; the engine refuses a null it forbids.
20
21
  - Omitting a key on `add` writes the column's type default — declared `default` if set, `[]` for a list, `{}` for obj/json, else `null`. That `null` is EMITTED, not what the row holds: the engine applies the column's nullability, so a `nullable` column keeps `null` and a non-nullable one lands on its type's zero value (`""`, `0`, `false`). Set the cell when the stored value matters. On `edit` an omitted key keeps its stored value.
21
22
  - An `f.password()` cell takes the PLAINTEXT — the column hashes on write, so a pre-hashed value, or a hashing filter on the cell, stores a hash of a hash that `security.check_password` can never match.
23
+ - A `table({ seed })` cell hashes the same way: the import writes the plaintext through the column's own rules, so a seeded credential matches under `security.check_password` exactly as an added one does. Demo accounts work as fixtures — the usual caution about seed data applies, since the plaintext sits in the repo.
22
24
  - `s.db.edit({ table, fieldName?, fieldValue, row?, data?, output?, as? })` — update by field match.
23
- - `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` — merge a partial (`data` is an object value).
25
+ - `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` — merge a partial. ⚠ Unlike `db.edit`'s `row`, `data` is a single object `Value` — write `obj({ unread: c.int(0) })`, not the column-keyed record `row` takes.
24
26
  On these three, `output` restricts the columns of the RETURNED row only — it does not change
25
27
  what is written. Not offered on `db.del`/`db.has` (their result is a scalar) or on
26
28
  `db.add_or_edit` (no output envelope).
@@ -29,6 +31,7 @@ primary key `id`):
29
31
  - `where` / `additionalWhere` — `expr(...)`, an `expr[]` (ANDed), or a raw `Value`. Rides `context.search`.
30
32
  - ⚠ `ignoreEmpty` DROPS the predicate when the operand is empty — it does not match zero rows. On an `in` comparison an empty list therefore returns the UNFILTERED set, so never use it to scope rows to a permitted-id list: an empty list of permissions returns everything.
31
33
  - For the full operator set use `cmp(left, op, right, { ignoreEmpty? })` — `op`: `in`/`not in`/`like`/`ilike`/`between`/`contains`/`includes`/`overlaps`/`@>`/`~`/`search`/… plus the `expr` comparisons. Database-only — a runtime condition takes the `expr` set only.
34
+ - ⚠ `like`/`ilike` take the operand as the PATTERN, verbatim: a bare term matches only an exact whole-string equal, and the endpoint answers HTTP 200 with zero rows — nothing reports a problem, so a search box that matches nothing ships. For substring matching use `includes`/`not includes`, which wrap the operand in `%…%` themselves and match case-INSENSITIVELY. Prefer them over a hand-built `"%" + term + "%"`, which is non-empty even for an empty term and so defeats `ignoreEmpty`; `includes` composes with it. `contains`/`@>`/`overlaps` are JSON/array containment, not text — on a text column they 400 `ParseError: Invalid value for param`.
32
35
  - Compose nested boolean logic with `and(...)` / `or(...)` groups (also available on `addon()` `where`).
33
36
  - An operand may be a bare value (`col`/`inp`/`ref`/`auth`/`c.*`) OR a **filtered** value (`withFilters(...)`) inline — the engine compiles the string and arithmetic filters (`trim`, `concat`, `upper`, `lower`, …) into the SQL.
34
37
  - ⚠ The REQUEST-TIME timestamp filters have no SQL form and kill the request with a bare fatal naming nothing: `epochms_transform`, `epochms_add_ms`, `epochms_add_secs`, `epochms_date`, `epochms_from_format`. For a relative cutoff use the SQL-side family instead (`epochms_add_day`, `epochms_sub_month`, `epochms_year`, …) or compute it in an earlier `s.set_var` and `ref()` that. `export --strict` reports it; a bare `c.now()` operand is always fine.
package/llms/tests.md CHANGED
@@ -42,7 +42,7 @@ The run is isolated in ways that make a correct test fail for reasons the failur
42
42
 
43
43
  - The run uses an EMPTY datasource, so **no `table({ seed })` rows exist while it runs** and every `db` read misses. A test that buys seeded row 1 fails with its own precondition message, which reads as a wrong id rather than an empty database. Create what the test needs INSIDE the test — typically a `defineFunction` fixture the stack calls first.
44
44
  - `s.api.call` does NOT raise when the endpoint answers with an error. It BINDS the error envelope (`{code, message}`) to its `as` and carries on, so a later `s.expect.to_be_defined({ expr: ref("r.field") })` reports the ASSERTION while the real failure was the call, four statements up. Assert on the envelope — `s.expect.to_contain({ expr: ref("r.code"), value: c.text("ERROR_CODE_INPUT_ERROR") })` — when a call may fail. `s.function.run` raises instead; the two disagree.
45
- - `s.expect.to_throw({ body, exception? })` runs `body` in an ISOLATED var stack, so a variable bound EARLIER in the test is not visible inside it — bind what the body needs inside the body. `exception` is text the raised message must CONTAIN; omit it to accept any error.
45
+ - `s.expect.to_throw({ body, exception? })` runs `body` in an ISOLATED var stack, so a variable bound EARLIER in the test is not visible inside it — bind what the body needs inside the body. `exception` is a `Value` whose text the raised message must CONTAIN (`c.text("already exists")`, not a bare string); omit it to accept any error.
46
46
  - `s.expect.to_throw` catches such a call only when the error carries a MESSAGE. `ERROR_CODE_ACCESS_DENIED` arrives with an empty one, so `to_throw` around an auth-refused call reports `to_throw failed - response is ok` — which reads as a broken auth gate on a gate that works.
47
47
  - An endpoint's `auth` gate is NOT enforced on `s.api.call`. A `query({ auth: users })` runs anyway and fails only where its stack dereferences `auth(...)`. A stack that never touches `auth(...)` runs unauthenticated and passes.
48
48
  - Neither `auth.token` nor an `Authorization` entry in `headers` authenticates the call — a token that answers 200 over real HTTP is refused here. To cover auth-gated logic, move the body into a `defineFunction` taking the user id and `s.function.call` that; the gate itself is not reachable from a workflow test.
@@ -55,4 +55,5 @@ The run is isolated in ways that make a correct test fail for reasons the failur
55
55
  - `xanots test list` shows what is there without running it; `xanots test run "<name>"` runs one. When a name is ambiguous the error prints the qualified `function:math/happy path` form, which `run` also accepts.
56
56
  - `--kind unit|workflow` narrows to one family. `--concurrency <n>` defaults to 1: tests share the environment database.
57
57
  - A failing suite exits 5, distinct from a crash. Tests that could not be REACHED exit 6 — retry that one, investigate the other. An environment with no tests is success, not failure.
58
+ - For CI, the exit code says THAT something failed and the JSON says WHICH. Every progress line goes to stderr and stdout carries one JSON document — emitted whenever stdout is not a terminal, or on demand with `--json`. `run-all` and `run`: `{ dest, env, total, passed, failed, tests: [{ kind, name, object?, status: "pass"|"fail", message?, timing? }] }`, with the same keys on an empty suite. `list` is `{ dest, env, total, tests: [...] }` and `deploy --test` nests the run under `testRun`. The per-test array is always `tests`.
58
59
  - `xanots deploy ./index.ts --test` deploys and then runs the suite against what it just shipped. A failure exits 5 WITHOUT retracting the deploy — the environment is live either way.