@xanots/sdk 0.0.8 → 0.0.10

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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +28 -21
  3. package/dist/.build-fingerprint +1 -1
  4. package/dist/{agent-file-refresh-4W37DGXS.js → agent-file-refresh-QNKN5RYD.js} +3 -3
  5. package/dist/bin.js +8 -7
  6. package/dist/{branch-commands-PBQ36L4A.js → branch-commands-2BLOC2GR.js} +13 -11
  7. package/dist/{capture-7PO6SGB4.js → capture-4WVJY4DQ.js} +2 -2
  8. package/dist/{chunk-ZOYMZZ3S.js → chunk-3INK4Y4E.js} +1 -1
  9. package/dist/{chunk-NDZFBZHC.js → chunk-3UABJA4X.js} +1 -1
  10. package/dist/{chunk-7AXQKABT.js → chunk-5R73LFWK.js} +3 -3
  11. package/dist/{chunk-CDAQUY5W.js → chunk-5YCQ2QHH.js} +2 -2
  12. package/dist/{chunk-2ROR3AZC.js → chunk-75Z74TA7.js} +2 -2
  13. package/dist/{chunk-QTNO2WD6.js → chunk-7ZYW652H.js} +1 -1
  14. package/dist/{chunk-SNO3KHCY.js → chunk-ACCBOMCB.js} +109 -78
  15. package/dist/{chunk-4PTSTDIG.js → chunk-AIZKXUNP.js} +2 -2
  16. package/dist/{chunk-P5C2YKAM.js → chunk-ANUDXFEX.js} +18 -27
  17. package/dist/chunk-CTD5ZCV6.js +28 -0
  18. package/dist/{chunk-5HCDZ2XN.js → chunk-DBFU47BJ.js} +2 -2
  19. package/dist/{chunk-OKNSR7MT.js → chunk-DIA7CT7J.js} +129 -150
  20. package/dist/{chunk-JQLR64UC.js → chunk-F6CYJ7TN.js} +21 -6
  21. package/dist/{chunk-BKDJN76G.js → chunk-FE5I6S6N.js} +1 -1
  22. package/dist/{chunk-EM5QPTXP.js → chunk-I7DQDJAM.js} +30 -48
  23. package/dist/{chunk-QIWBCS7N.js → chunk-JGCWTCA7.js} +2 -2
  24. package/dist/{chunk-MDXR5E5Q.js → chunk-K5IOND4K.js} +2 -2
  25. package/dist/{chunk-4PE4ZAB7.js → chunk-KA6G2L7U.js} +5 -5
  26. package/dist/{chunk-64QK6JEK.js → chunk-LBYWGMOA.js} +36 -2
  27. package/dist/{chunk-3MDKV2VA.js → chunk-P6TAVLOX.js} +2 -2
  28. package/dist/{chunk-3QW5NFZJ.js → chunk-QKM4U5UK.js} +3 -3
  29. package/dist/{chunk-T4XPCJRF.js → chunk-TCFIPDB3.js} +1 -1
  30. package/dist/{chunk-FEQTQ6PM.js → chunk-VKSOTZK3.js} +2 -2
  31. package/dist/{chunk-6DHBYBTO.js → chunk-VNQM3V2C.js} +1 -2
  32. package/dist/{chunk-PYS7UNNW.js → chunk-W24FJHPD.js} +3 -3
  33. package/dist/chunk-WGPXT2G2.js +22 -0
  34. package/dist/{chunk-6AAT2AYQ.js → chunk-WP4OZZV4.js} +2 -2
  35. package/dist/{chunk-MU3O43L2.js → chunk-YBC3IKMF.js} +2 -2
  36. package/dist/{chunk-C3M5K4ZH.js → chunk-YYFXVYPX.js} +37 -6
  37. package/dist/chunk-ZQ2PKR6R.js +40 -0
  38. package/dist/cli.d.ts +57 -31
  39. package/dist/cli.js +6 -5
  40. package/dist/codegen-command-GB3H2KQ7.js +46 -0
  41. package/dist/{completion-A6BZ3XGU.js → completion-WF46272M.js} +2 -2
  42. package/dist/{config-NL33PN4D.js → config-476F3PT5.js} +1 -1
  43. package/dist/{deploy-command-QZ3GNLAD.js → deploy-command-QTIVA22A.js} +68 -100
  44. package/dist/{ephemeral-command-DL7EY4LL.js → ephemeral-command-U4AQ3TXX.js} +25 -28
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +5 -5
  47. package/dist/{init-command-4MG3UKUO.js → init-command-MAXULNAD.js} +13 -10
  48. package/dist/{onboard-command-EHOHKQQU.js → init-web-3JNFG6GI.js} +10 -10
  49. package/dist/internal.d.ts +2 -2
  50. package/dist/internal.js +42 -45
  51. package/dist/{live-diff-QK4KC2FJ.js → live-diff-IXKBVG4K.js} +3 -3
  52. package/dist/{lock-commands-Y4S4NUCE.js → lock-commands-DQ7CUMIV.js} +20 -19
  53. package/dist/{login-command-DFCURZLC.js → login-command-Z6CHTA57.js} +7 -7
  54. package/dist/{logout-command-WXVZQCAV.js → logout-command-ER6IKAYJ.js} +2 -2
  55. package/dist/{loop-7SAIGRCZ.js → loop-D5NPL4VH.js} +3 -3
  56. package/dist/{marketplace-command-BCOAAN2I.js → marketplace-command-P4IPLJ6J.js} +6 -6
  57. package/dist/{meta-client-57ZWVHST.js → meta-client-K2J4XH64.js} +6 -6
  58. package/dist/node.d.ts +2 -2
  59. package/dist/node.js +9 -8
  60. package/dist/{validate-command-GLF5ZOOW.js → preflight-command-GZ2GE5RN.js} +17 -16
  61. package/dist/{profile-command-QJAAUZIV.js → profile-command-LJSBDV2L.js} +5 -5
  62. package/dist/{release-command-DN75G5GJ.js → release-command-YUBTNHVX.js} +36 -33
  63. package/dist/{routes-manifest-SP3ZXLMR.js → routes-manifest-PWZHDOI5.js} +2 -2
  64. package/dist/scaffold.js +2 -2
  65. package/dist/{static-host-D6KS7X45.js → static-host-3WMV7IZO.js} +1 -1
  66. package/dist/status-command-AL47VG7H.js +162 -0
  67. package/dist/{store-CUCBSYLj.d.ts → store-BG1UPZ3Z.d.ts} +5 -5
  68. package/dist/{test-command-RRNNJ45I.js → test-command-YAZLKLGQ.js} +46 -29
  69. package/dist/{upgrade-command-XFOAWDI3.js → upgrade-command-FB5QJ363.js} +13 -12
  70. package/dist/{workspace-K72NP7SX.js → workspace-2COHDBM3.js} +1 -1
  71. package/dist/{workspace-command-QCFELEGR.js → workspace-command-43P42FBP.js} +31 -32
  72. package/dist/{workspace-export-AJMGN3CQ.js → workspace-export-DURY5WYL.js} +2 -2
  73. package/guides/README.md +2 -2
  74. package/guides/authoring.md +9 -1
  75. package/guides/cli.md +17 -19
  76. package/guides/codegen.md +21 -5
  77. package/guides/coverage.md +1 -1
  78. package/guides/deploying.md +31 -34
  79. package/guides/environment.md +4 -4
  80. package/guides/object-kinds.md +3 -3
  81. package/guides/project-structure.md +1 -1
  82. package/guides/scaffold.md +25 -2
  83. package/guides/typed-frontend.md +1 -1
  84. package/llms/fields.md +3 -2
  85. package/llms/kinds-agent-mcp.md +1 -1
  86. package/llms/kinds-core.md +1 -1
  87. package/llms/kinds-realtime.md +1 -1
  88. package/llms/lock.md +5 -5
  89. package/llms/statements-calls.md +2 -1
  90. package/llms/statements-data.md +5 -2
  91. package/llms/tests.md +3 -2
  92. package/llms-full.txt +41 -44
  93. package/llms.txt +20 -29
  94. package/manifest.json +95 -426
  95. package/package.json +1 -1
  96. package/dist/chunk-3EYUR3TX.js +0 -100
  97. package/dist/chunk-JQJPFUZI.js +0 -118
  98. package/dist/chunk-XP7S3VWY.js +0 -78
  99. package/dist/codegen-command-U2W72DLG.js +0 -43
  100. package/dist/env-target-NAYVEXBH.js +0 -16
  101. package/dist/sandbox-details-command-EDO56IY3.js +0 -18
  102. package/dist/sandbox-export-command-MO6DEZ7Z.js +0 -24
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # xanots v0.0.8
1
+ # xanots v0.0.10
2
2
 
3
3
  > TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
4
4
 
@@ -45,12 +45,12 @@ installed), so a plain file read resolves them at the version you have.
45
45
  - [Triggers](llms/triggers.md): Read when authoring any trigger. A trigger's `stack` is a callback rather than the plain array every other kind takes, so the shape does not carry over.
46
46
  - [Array and database statements](llms/statements-data.md): Read when the stack reads or writes rows (`s.db.*`), or transforms an array in place (`s.array.map`, `s.array.union`).
47
47
  - [Statement runtime behavior](llms/statements-runtime.md): Read when you need to know what a statement's `as:` output actually holds, or why a bound variable is not the shape you expected.
48
- - [Auth, cross-object calls, and microservices](llms/statements-calls.md): 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.
48
+ - [Auth, cross-object calls, and microservices](llms/statements-calls.md): 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.
49
49
  - [Value catalog](llms/values.md): Read when you need a literal, a reference, or a tag you have not used before — `c.*`, `ref`, `inp`, `auth`, `col`, and what each one encodes to.
50
50
  - [Column and input types](llms/fields.md): Read when declaring a table column (`f.*`) or a function/query input (`input.*`) — a type's options and accessor methods, and the `s.precondition` error/status contract that rides the same catalog.
51
51
  - [Filter catalog](llms/filters.md): Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
52
52
  - [Lambda bodies (JavaScript)](llms/lambda.md): Read when writing a JavaScript body, or weighing whether to reach for one at all — `lam.fn`, `fl.lambda`, `fl.reduce`, `s.lambda`. Lambda workers are a bounded shared resource, and each surface binds a different set of identifiers.
53
- - [Lock file](llms/lock.md): Read when a `xano.lock` exists or should — renaming/pruning/adopting identities, seeding the lock programmatically, or asking which commands write it.
53
+ - [Lock file](llms/lock.md): Read when a `xano.lock` exists or should — renaming/pruning/importing identities, seeding the lock programmatically, or asking which commands write it.
54
54
  - [Legacy paradigms and retired statements](llms/legacy.md): Read when the code was PULLED from an existing Xano instance rather than authored here — how a codegen'd tree reads and the three shapes in it that must not be "fixed", plus a `raw({ name: "mvp:…" })` statement, a `realtimeTrigger()`, or any name the catalogs do not list.
55
55
  - [Statement catalog](llms/statements-catalog.md): Read for the field signature of a specific statement — every surface, grouped by `s.*` namespace. Look here after the control-flow core in the router does not cover what you need.
56
56
 
@@ -115,7 +115,7 @@ which fails with a "must be ES modules" error until you switch it to module.
115
115
 
116
116
  Set `canonical` on every `apiGroup`. The engine mints the URL token server-side, so
117
117
  without one a group's client paths are unresolvable until a lock exists: the bundle
118
- exports fine and `xanots paths` / `getPath()` then fail on the very queries it just
118
+ exports fine and `xanots routes` / `getPath()` then fail on the very queries it just
119
119
  built. An explicit `canonical` resolves them from the source alone.
120
120
 
121
121
  Build warnings: `export()` prints the shapes that deploy clean and then do the wrong
@@ -134,7 +134,7 @@ api-group/toolset canonical in a lock file written BESIDE THE ENTRY FILE —
134
134
  To rename an object: rename in code, export (stderr prints the exact fix-up), run
135
135
  `xanots lock rename <kind> <old> <new>`, export again — the original guid is emitted
136
136
  under the new name, so the engine renames in place instead of delete+create. Taking
137
- over an existing workspace: `xanots lock adopt <its-packageExport.json>` first, then
137
+ over an existing workspace: `xanots lock import <its-packageExport.json>` first, then
138
138
  export. Pruning, programmatic seeding, and which commands write the lock:
139
139
  `llms/lock.md`.
140
140
 
@@ -147,19 +147,19 @@ environment and prints its URL.
147
147
  AND records — before importing. The blast radius is a disposable environment, not a
148
148
  production workspace, but confirm with the user before the first run.
149
149
 
150
- **Two destinations, and the choice changes more than the target.**
150
+ **One destination, and no flag for it.**
151
151
 
152
- - `--dest ephemeral` (DEFAULT) a NAMED, workspace-scoped, auto-expiring tenant
153
- (~1h; `--expires-hours` 1–72 at create time). The active one is tracked in
152
+ - `xanots deploy` writes to a NAMED, workspace-scoped, auto-expiring ephemeral tenant
153
+ (~1h; `--expires-hours` 1–72 at create time), and to nothing else an `--env` here
154
+ is a usage error, not a choice. The active env is tracked in
154
155
  `./.xano/ephemeral.json`, so deploying again REFRESHES it and the URL is unchanged;
155
156
  if it expired or was swept, a fresh one is created and the new URL is called out.
156
157
  `--static` puts the frontend ON THE EPHEMERAL, so backend and frontend share one
157
158
  disposable environment.
158
159
  ⚠ Only the BACKEND URL survives a refresh: the replace clears static hosting too,
159
160
  so `--static` publishes a NEW host every run and the previous URL stops serving.
160
- - `--dest sandbox` your single throwaway tenant, no expiry. `--static` puts the
161
- frontend on your OWN (parent) workspace instead, because the sandbox tenant does
162
- not serve static hosting.
161
+ - `xanots status` names the env this project last deployed to, its URL and its expiry,
162
+ without your having to remember which one it was.
163
163
  - `xanots release` promotes to your INSTANCE workspace and MERGES, not replaces:
164
164
  adds/updates what you define, deletes nothing, writes no rows. Destruction is
165
165
  opt-in per flag, previewed + confirmed, and can drop a table WITH its rows.
@@ -182,7 +182,7 @@ host falls back to '', and every call 404s off the dev server.
182
182
  ⚠ It is INJECTED in bracket form — `window["XANO_HOST"]="…"` — so verifying a deploy
183
183
  by grepping `window.XANO_HOST` matches nothing and reads as a failed inject. Grep the
184
184
  bare `XANO_HOST` token.
185
- ⚠ `xanots validate` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
185
+ ⚠ `xanots preflight` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
186
186
  `XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) from the environment.
187
187
  **Displaying a stored file.** A file column comes back as `{ path, name, type, size,
188
188
  meta, access, url }`. ⚠ Do NOT use its `url`: on a tenant-scoped environment that field
@@ -332,7 +332,7 @@ Non-obvious authoring rules:
332
332
  never removes it. The floor is the RUNTIME, not the def: a second def, or a much richer
333
333
  one, adds ~1 kB. So the cost is paid by importing ANY def at all, and reducing what a
334
334
  def does will not reduce it.
335
- Fix: `xanots paths <entry> --emit xano/routes.gen.ts` (`routes` is an accepted alias) — verbs, paths, and sockets as
335
+ Fix: `xanots routes <entry> --emit xano/routes.gen.ts` (`paths` is an accepted alias) — verbs, paths, and sockets as
336
336
  plain data importing NOTHING, still compile-checked: `routePath("blog/{slug}", { slug })`,
337
337
  `channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
338
338
  URLs lifted to `wss://h/ws/<tenant>:<canonical>`). A rename is a type error, not a 404.
@@ -358,7 +358,7 @@ Non-obvious authoring rules:
358
358
  `s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`
359
359
  take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);
360
360
  `s.group(body)` and `s.util.post_process(body)` take it **positionally**.
361
- `s.for` is **count-bounded** (`{ as, count, body }`), not from/to. See the
361
+ `s.for` is **count-bounded** (`{ as, count: <Value>, body }`), not from/to. See the
362
362
  authored signatures in `llms/statements-data.md`.
363
363
  - **MCP servers & agents are distinct root kinds** that both persist under the
364
364
  `toolset` payload key (so a same-name pair collides). `mcpServer({...})` exposes
@@ -419,19 +419,10 @@ Non-obvious authoring rules:
419
419
  credential the runner has. As a file that triple is `{ "type": "token",
420
420
  "instance_base_url": …, "workspace_id": <n>, "meta_api_token": … }`. The older
421
421
  `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works but ROTATES: single-use.
422
- - **Event-driven objects fire on an EPHEMERAL, not in the sandbox.** A `task`
423
- (scheduled), an `mcpServer`, and every trigger — `tableTrigger` included — run normally
424
- on an ephemeral env, which is `deploy`'s DEFAULT destination. So test an event-driven
425
- design (screen-on-insert, cron cleanup, MCP tool call) by deploying it and letting it
426
- run.
427
- ⚠ Under `--dest sandbox` they import cleanly but their stacks NEVER execute, and there
428
- is no way to fire one manually — an insert on a bound table does not run its
429
- `tableTrigger`, and the design silently does nothing. Only synchronously-invoked objects
430
- (queries, functions, and the agents an endpoint calls with `s.ai.agent.run`) run there.
431
- If you must stay on the sandbox, verify the logic out of band: factor the body into a
432
- `defineFunction` (or a callable `query`) and invoke it directly — a `tableTrigger` that
433
- screens a row on insert should delegate to a function a `query` can also call via
434
- `s.function.run`, and you assert against that.
422
+ - **Event-driven objects fire on an EPHEMERAL.** A `task` (scheduled), an `mcpServer`,
423
+ and every trigger — `tableTrigger` included — run normally on an ephemeral env, which
424
+ is where `deploy` sends them. So test an event-driven design (screen-on-insert, cron
425
+ cleanup, MCP tool call) by deploying it and letting it run.
435
426
  - **Zero-based numeric keys make `c.obj` a LIST.** `c.obj({ "0": "a" })` evaluates to
436
427
  `["a"]`: a numeric key IS an index in the engine's data model, so keys that are exactly
437
428
  `0..n-1` come back as a list with HTTP 200 and no error. Write `c.array([...])` when you
@@ -474,14 +465,14 @@ Control flow & blocks (each nests a sub-stack; block specials name it `body`):
474
465
  - **Every** statement takes `disabled?`/`description?` — 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 })`).
475
466
  - **Statements with an `as`** also take `asFilters?` — `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()]` → `number`); filters whose result the engine declares as `any` (`get`, `set`, `json_decode`, …) fold to `unknown`.
476
467
  - `s.conditional({ when, then, elif?, else? })` — 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[]`.
477
- - `s.for({ as, count, body })` — **count-bounded** loop (`as` is the index), NOT from/to.
468
+ - `s.for({ as, count, body })` — **count-bounded** loop (`as` is the index), NOT from/to; `count` is a `Value`, not a bare number.
478
469
  - `s.foreach({ as, list, body })` — iterate `list`; `as` is the current item.
479
470
  - `s.while({ when, body })` — `when` is a condition (`expr`/`cmp`/`and`/`or`).
480
471
  - `s.switch({ on, cases: [{ when, body, break? }], default? })` — multi-way branch on a subject `Value` `on`; each `case`'s `when` is a literal `Value` matched against `on` (NOT a comparison — use `s.conditional` for `<`/`>`/ranges). ⚠ **Omitting `break: true` FALLS THROUGH** — the matched case also runs every LATER case body. Type-checks clean; only `export --strict` catches it.
481
472
  - `s.try_catch({ try, catch?, finally? })` — three `Statement[]` blocks.
482
473
  - `s.group(body)` / `s.util.post_process(body)` — take a `Statement[]` **positionally**.
483
474
  - `s.foreach_break()` / `s.foreach_continue()` / `s.foreach_remove()` — nullary loop control.
484
- - `s.expect.to_throw({ body, exception? })` — `body` is the statements expected to raise.
475
+ - `s.expect.to_throw({ body, exception? })` — `body` is the statements expected to raise; `exception` is a `Value`, not a bare string.
485
476
 
486
477
  # Object kinds
487
478
 
@@ -539,7 +530,7 @@ to survive a rename).
539
530
  - ⚠ Under `"custom"`, `allowOrigins` is matched as EXACT strings (scheme+host+port, no wildcard or subdomain expansion) and `"*"` is compared as a literal origin — it matches NOTHING. An unmatched origin gets no `access-control-*` headers at all, so the call fails in the browser on a missing `access-control-allow-origin` while export, deploy and the preflight all look fine. Name each origin, or use `mode: "default"` for any-origin. `allowMethods` gates the REAL response too: a verb left off gets no CORS headers back even though its preflight passes. Export warns on an empty origin list, a `"*"` entry, and a policy with no method enabled.
540
531
  - `defineFunction`/`query`/`apiGroup` above cover the queries+tables core; the four below are the "reach past that" primitives (tasks, workflow tests, middleware, tools). Agents and MCP servers are the same family and live in `llms/kinds-agent-mcp.md`. Same envelope conventions (`guid?`, `description?`, `docs?`, `tags?`, `history?`) unless noted.
541
532
  - `task({ name, guid?, description?, docs?, datasource?, active?, tags?, history?, schedule?, stack?, middleware? })` — a scheduled background job (function-like `stack`, no `input`/`response`).
542
- - `schedule?`: a `ScheduleDef[]` (NOT a single object) — `{ startsOn, freq?, repeatEnabled?, endsOn?, endsEnabled? }`. `startsOn`/`endsOn` are **timestamp strings** validated at encode time — `"2026-01-01T00:00:00Z"`, or the space-separated `"2026-01-01 00:00:00+0000"` a pulled workspace carries — never epoch numbers, and never zoneless; `freq` is the repeat interval **in seconds** (default `86400` = daily); `endsOn` present ⇒ the schedule has an end. `endsEnabled?` defaults to that and is recovery-only — state it to reproduce a stored schedule that remembers an end date with the gate OFF. Fires on an ephemeral; does NOT fire in the sandbox (see Gotchas).
533
+ - `schedule?`: a `ScheduleDef[]` (NOT a single object) — `{ startsOn, freq?, repeatEnabled?, endsOn?, endsEnabled? }`. `startsOn`/`endsOn` are **timestamp strings** validated at encode time — `"2026-01-01T00:00:00Z"`, or the space-separated `"2026-01-01 00:00:00+0000"` a pulled workspace carries — never epoch numbers, and never zoneless; `freq` is the repeat interval **in seconds** (default `86400` = daily); `endsOn` present ⇒ the schedule has an end. `endsEnabled?` defaults to that and is recovery-only — state it to reproduce a stored schedule that remembers an end date with the gate OFF. Fires on an ephemeral (see Gotchas).
543
534
  - `workflowTest({ name, guid?, description?, docs?, datasource?, active?, tags?, stack? })` — an end-to-end test. NO `input`/`response`: `.call` something with an `as`, then assert on that var — `s.function.call({ fn, input, as: "r" })`, `s.expect.to_equal({ expr: ref("r"), value: c.int(42) })`. `s.expect.*` belongs here — it is not inert elsewhere (a failure 500s the request), so treat one in a query/function/task as a mistake to remove. `active?` defaults `true`; chain tests with `s.workflow_test.call({ workflowTest: <def handle> })`.
544
535
  - `datasource?`: **the trap.** Default `""` is an EMPTY datasource (recommended), not "no datasource". Any non-empty name makes the engine CLONE it before EVERY run — against production-sized data, slow enough to fail the run. `"live"` warns at compile time; other names don't.
545
536
  - `middleware({ name, guid?, description?, docs?, resultStrategy?, exceptionPolicy?, tags?, history?, input?, stack?, response?, responseShape?, tests? })` — a pre/post interceptor (function-like `stack`); attach it via a host's `middleware: { pre, post }`. ⚠ `input` ENCODES but an ATTACHED middleware never has it bound — the host request binds its own inputs, so `inp()` inside pre/post fails at runtime with `Unable to locate input` and a declared default does not stand in (`export()` warns). Read the request body with `s.util.get_all_input` instead; it yields a `{ type, vars }` envelope. `s.middleware.call` is the one path that DOES bind the declared map.
@@ -630,7 +621,7 @@ The run is isolated in ways that make a correct test fail for reasons the failur
630
621
 
631
622
  - 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.
632
623
  - `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.
633
- - `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.
624
+ - `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.
634
625
  - `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.
635
626
  - 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.
636
627
  - 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.
@@ -639,17 +630,18 @@ The run is isolated in ways that make a correct test fail for reasons the failur
639
630
 
640
631
  `xanots test run-all` runs the unit tests AND the `workflowTest()` objects an environment carries. It takes no entry file and compiles nothing: it runs what is DEPLOYED, so deploy before testing.
641
632
 
642
- - `--dest ephemeral` (DEFAULT, `--name <env>` to pick one), `--dest sandbox`, or `--dest workspace`. Unlike `deploy`, `workspace` is allowed here running a test reads.
633
+ - `--env ephemeral` (DEFAULT the one this project last deployed to), `--env ephemeral:<name>`, or `--env workspace`. Same grammar as `init --from`. `deploy` takes no `--env` at all; `test` does, and `workspace` is allowed here because running a test only reads.
643
634
  - `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.
644
635
  - `--kind unit|workflow` narrows to one family. `--concurrency <n>` defaults to 1: tests share the environment database.
645
636
  - 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.
637
+ - 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`.
646
638
  - `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.
647
639
 
648
640
  # Agent and MCP def shapes
649
641
 
650
642
  > Read when the workspace defines an `agent()` or an `mcpServer()`.
651
643
 
652
- - `mcpServer({ name, guid?, description?, instructions?, docs?, enabled?, canonical?, spec?, tags?, history?, tools?, llm?, output? })` — an MCP toolset. `llm?`/`output?` are the same blocks `agent()` takes and are usually absent: an MCP server and an agent are ONE stored object distinguished by `type`. Returns a handle with `getPath()`/`getUrl(baseUrl)` for the Streamable-HTTP endpoint — `getUrl` is NOT idempotent, a `baseUrl` already carrying an `/x2/mcp/<…>/stream` path THROWS, so resolve ONCE from the instance base URL. Fires on an ephemeral; not in sandbox.
644
+ - `mcpServer({ name, guid?, description?, instructions?, docs?, enabled?, canonical?, spec?, tags?, history?, tools?, llm?, output? })` — an MCP toolset. `llm?`/`output?` are the same blocks `agent()` takes and are usually absent: an MCP server and an agent are ONE stored object distinguished by `type`. Returns a handle with `getPath()`/`getUrl(baseUrl)` for the Streamable-HTTP endpoint — `getUrl` is NOT idempotent, a `baseUrl` already carrying an `/x2/mcp/<…>/stream` path THROWS, so resolve ONCE from the instance base URL. Fires on an ephemeral.
653
645
  - `tools?`: a `ToolsetToolEntry[]`. Pass the `tool()` HANDLES directly (`tools: [saveNote]`), like every other collection in the SDK; use the `{ tool, enabled?, auth? }` wrapper only when a tool needs `enabled: false` or per-tool `auth`. `auth` names an auth **table** (a `table({ auth: true })` handle or its name) — Xano's ONLY MCP auth surface (per-tool; there is no server-level gate). An entry that names no tool (no handle, no `id`) THROWS at export rather than emitting the `id: 0` null reference it used to; a deliberate raw `id: 0` warns and is carried through, so a pulled workspace still round-trips.
654
646
  - `agent({ name, guid?, description?, docs?, enabled?, canonical?, tags?, history?, llm, tools?, output? })` — an LLM orchestrator. No top-level `instructions`/`prompt`/`spec` — the prompt lives under `llm`. Invoke from a stack with `s.ai.agent.run({ agent, args })`.
655
647
  - `llm` (REQUIRED): typed provider settings, a discriminated union on `type` (`"xano-free" | "anthropic" | "openai" | "google-genai"`). Shared fields: `systemPrompt?`, `maxSteps?` (default `5`), and `prompt?` XOR `messages?` (genuinely exclusive: both is a type error and throws — the engine stores ONE `prompt_type`, so one would be dropped); plus provider fields (`apiKey?`, `model?`, `temperature?`, `reasoningEffort?`, …). String fields accept Twig placeholders — `{{ $args.x }}` for run inputs (the `args` of `s.ai.agent.run`), `{{ $env.NAME }}` for env vars.
@@ -727,7 +719,7 @@ The run is isolated in ways that make a correct test fail for reasons the failur
727
719
  - **Tenant instances (isolated DB):** a tenant's realtime objects live in the TENANT's database, so BOTH halves of a client must name the tenant.
728
720
  - Socket: `server.getUrl(base, { tenant })` → `/ws/<tenant>:<canonical>`. ⚠ A bare canonical on a tenant host resolves against the INSTANCE workspace instead.
729
721
  - That colon form is PECULIAR TO THE SOCKET. Every other tenant URL gives the tenant its OWN segment — the HTTP half of the same client is `https://<host>/tenant/<tenant>/api:<canonical>/…`. NO request header is required for either.
730
- - Because the shapes differ, `getUrl` TRANSLATES a tenant base URL instead of concatenating: pass the `https://<host>/tenant/<name>` that `sandbox details` prints (and that deploy injects as `window.XANO_HOST`) and the tenant is LIFTED into the socket form. So `getUrl(window.XANO_HOST)` needs no `{ tenant }`, and a CONFLICTING `{ tenant }` alongside it throws.
722
+ - Because the shapes differ, `getUrl` TRANSLATES a tenant base URL instead of concatenating: pass the `https://<host>/tenant/<name>` that `xanots status` prints (and that deploy injects as `window.XANO_HOST`) and the tenant is LIFTED into the socket form. So `getUrl(window.XANO_HOST)` needs no `{ tenant }`, and a CONFLICTING `{ tenant }` alongside it throws.
731
723
  - ⚠ `getUrl`/`socketUrl` are NOT idempotent — a `baseUrl` that already carries a `/ws/<…>` path (an earlier result of either) THROWS. Resolve ONCE from the http(s) base; pass that result to `new WebSocket`, never back in as a base.
732
724
  - Still pass `{ tenant }` explicitly for a tenant on its OWN DOMAIN — the hostname carries it for HTTP, but there is nothing in the URL for the socket to lift.
733
725
  - ⚠ Tokens are tenant-scoped (audience `<tenant>:<license>`, not the bare license), so one minted through the instance workspace is REJECTED by a tenant's realtime server — authenticate and dial through the same tenant.
@@ -792,7 +784,8 @@ DB reads/writes (`table` is a def handle or name; `fieldName` defaults to the
792
784
  primary key `id`):
793
785
 
794
786
  - `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).
795
- - `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.
787
+ - `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.
788
+ - ⚠ `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 })`.
796
789
  - `s.db.has({ table, fieldName?, fieldValue, as? })` — existence test.
797
790
  - `s.db.del({ table, fieldName?, fieldValue, as? })` — delete by field match.
798
791
  - `s.db.add({ table, row?, data?, output?, as? })` — insert; `row` is a partial keyed by column.
@@ -800,8 +793,9 @@ primary key `id`):
800
793
  - `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.
801
794
  - 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.
802
795
  - 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.
796
+ - 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.
803
797
  - `s.db.edit({ table, fieldName?, fieldValue, row?, data?, output?, as? })` — update by field match.
804
- - `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` — merge a partial (`data` is an object value).
798
+ - `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.
805
799
  On these three, `output` restricts the columns of the RETURNED row only — it does not change
806
800
  what is written. Not offered on `db.del`/`db.has` (their result is a scalar) or on
807
801
  `db.add_or_edit` (no output envelope).
@@ -810,6 +804,7 @@ primary key `id`):
810
804
  - `where` / `additionalWhere` — `expr(...)`, an `expr[]` (ANDed), or a raw `Value`. Rides `context.search`.
811
805
  - ⚠ `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.
812
806
  - 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.
807
+ - ⚠ `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`.
813
808
  - Compose nested boolean logic with `and(...)` / `or(...)` groups (also available on `addon()` `where`).
814
809
  - 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.
815
810
  - ⚠ 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.
@@ -890,7 +885,7 @@ Runtime behavior (what the `as:` output holds, and misses):
890
885
 
891
886
  # Auth, cross-object calls, and microservices
892
887
 
893
- > 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.
888
+ > 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.
894
889
 
895
890
  Auth & calls:
896
891
 
@@ -906,6 +901,7 @@ Auth & calls:
906
901
  - `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.
907
902
  - `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`.
908
903
  - `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.
904
+ - `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.
909
905
  - `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.
910
906
  - `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.
911
907
  - `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.
@@ -986,8 +982,9 @@ an `int`, and a null in it is unqueryable: `null` is never a legal `fieldValue`/
986
982
  `s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather
987
983
  than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for
988
984
  "not set yet" — `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and
989
- binds `null`, which is the answer the null was reaching for. `export()` warns on a literal
990
- `c.null()` in that slot.
985
+ binds `null`, which is the answer the null was reaching for and never `s.db.get_by_id`,
986
+ which validates `id >= 1` and fails the whole request on the sentinel. `export()` warns on a
987
+ literal `c.null()` in that slot.
991
988
  An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through
992
989
  any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:
993
990
  "vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see
@@ -1361,7 +1358,7 @@ TypeScript annotations survive in the body, and top-level `await` works.
1361
1358
 
1362
1359
  # Lock file
1363
1360
 
1364
- > Read when a `xano.lock` exists or should — renaming/pruning/adopting identities, seeding the lock programmatically, or asking which commands write it.
1361
+ > Read when a `xano.lock` exists or should — renaming/pruning/importing identities, seeding the lock programmatically, or asking which commands write it.
1365
1362
 
1366
1363
  `xano.lock` pins each object's guid and each api-group/toolset canonical, so renames
1367
1364
  stay renames (guids otherwise derive from `(type, name)`; a query's from `(api group,
@@ -1374,11 +1371,11 @@ old key drops automatically once its guid re-lands under the composed one).
1374
1371
  - `xanots lock rename <kind> <old> <new>` — `kind` is the payload key (or `table`/`api_group`).
1375
1372
  Run it after renaming in code; the next export emits the original guid under the new name.
1376
1373
  - `xanots lock prune <entry-file> [keys…] --yes` — drops orphaned entries. Finding orphans
1377
- RUNS the entry's module scope (env assertions included); `--no-verify --yes <kind:name>…`
1374
+ RUNS the entry's module scope (env assertions included); `--identity-only --yes <kind:name>…`
1378
1375
  prunes named keys with no evaluation and no orphan check.
1379
- - `xanots lock adopt <live-bundle.json> [--yes]` — seed the lock from an engine
1376
+ - `xanots lock import <live-bundle.json> [--yes]` — seed the lock from an engine
1380
1377
  packageExport when taking over an existing workspace.
1381
- - Every lock subcommand accepts `--lock=<path>`. `rename`/`adopt` take no entry file, so
1378
+ - Every lock subcommand accepts `--lock=<path>`. `rename`/`import` take no entry file, so
1382
1379
  from outside the lock's directory pass `--lock` (or `--entry=<entry-file>` to derive it).
1383
1380
  - Programmatic use: call `seedLockOverrides(readLockFile(path))` BEFORE importing any def
1384
1381
  module — references bake guids at import time, so late seeding is a silent no-op
@@ -1386,7 +1383,7 @@ old key drops automatically once its guid re-lands under the composed one).
1386
1383
 
1387
1384
  What writes the lock: `export`/`deploy` of an ENTRY FILE update it via the shared compile
1388
1385
  step — only when a lock exists or `--lock` is passed. Nothing from a DEPLOY is written
1389
- back beyond that (an ephemeral/sandbox is a separate workspace, so its identities must
1386
+ back beyond that (an ephemeral is a separate workspace, so its identities must
1390
1387
  not pollute yours). The one write-back is `release --replace`, which mints fresh
1391
1388
  identities in the workspace the lock describes: it re-pins the lock from the rebuilt
1392
1389
  workspace, because otherwise the next release matches nothing and duplicates every
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # xanots v0.0.8
1
+ # xanots v0.0.10
2
2
 
3
3
  > TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
4
4
 
@@ -45,12 +45,12 @@ installed), so a plain file read resolves them at the version you have.
45
45
  - [Triggers](llms/triggers.md): Read when authoring any trigger. A trigger's `stack` is a callback rather than the plain array every other kind takes, so the shape does not carry over.
46
46
  - [Array and database statements](llms/statements-data.md): Read when the stack reads or writes rows (`s.db.*`), or transforms an array in place (`s.array.map`, `s.array.union`).
47
47
  - [Statement runtime behavior](llms/statements-runtime.md): Read when you need to know what a statement's `as:` output actually holds, or why a bound variable is not the shape you expected.
48
- - [Auth, cross-object calls, and microservices](llms/statements-calls.md): 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.
48
+ - [Auth, cross-object calls, and microservices](llms/statements-calls.md): 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.
49
49
  - [Value catalog](llms/values.md): Read when you need a literal, a reference, or a tag you have not used before — `c.*`, `ref`, `inp`, `auth`, `col`, and what each one encodes to.
50
50
  - [Column and input types](llms/fields.md): Read when declaring a table column (`f.*`) or a function/query input (`input.*`) — a type's options and accessor methods, and the `s.precondition` error/status contract that rides the same catalog.
51
51
  - [Filter catalog](llms/filters.md): Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
52
52
  - [Lambda bodies (JavaScript)](llms/lambda.md): Read when writing a JavaScript body, or weighing whether to reach for one at all — `lam.fn`, `fl.lambda`, `fl.reduce`, `s.lambda`. Lambda workers are a bounded shared resource, and each surface binds a different set of identifiers.
53
- - [Lock file](llms/lock.md): Read when a `xano.lock` exists or should — renaming/pruning/adopting identities, seeding the lock programmatically, or asking which commands write it.
53
+ - [Lock file](llms/lock.md): Read when a `xano.lock` exists or should — renaming/pruning/importing identities, seeding the lock programmatically, or asking which commands write it.
54
54
  - [Legacy paradigms and retired statements](llms/legacy.md): Read when the code was PULLED from an existing Xano instance rather than authored here — how a codegen'd tree reads and the three shapes in it that must not be "fixed", plus a `raw({ name: "mvp:…" })` statement, a `realtimeTrigger()`, or any name the catalogs do not list.
55
55
  - [Statement catalog](llms/statements-catalog.md): Read for the field signature of a specific statement — every surface, grouped by `s.*` namespace. Look here after the control-flow core in the router does not cover what you need.
56
56
 
@@ -115,7 +115,7 @@ which fails with a "must be ES modules" error until you switch it to module.
115
115
 
116
116
  Set `canonical` on every `apiGroup`. The engine mints the URL token server-side, so
117
117
  without one a group's client paths are unresolvable until a lock exists: the bundle
118
- exports fine and `xanots paths` / `getPath()` then fail on the very queries it just
118
+ exports fine and `xanots routes` / `getPath()` then fail on the very queries it just
119
119
  built. An explicit `canonical` resolves them from the source alone.
120
120
 
121
121
  Build warnings: `export()` prints the shapes that deploy clean and then do the wrong
@@ -134,7 +134,7 @@ api-group/toolset canonical in a lock file written BESIDE THE ENTRY FILE —
134
134
  To rename an object: rename in code, export (stderr prints the exact fix-up), run
135
135
  `xanots lock rename <kind> <old> <new>`, export again — the original guid is emitted
136
136
  under the new name, so the engine renames in place instead of delete+create. Taking
137
- over an existing workspace: `xanots lock adopt <its-packageExport.json>` first, then
137
+ over an existing workspace: `xanots lock import <its-packageExport.json>` first, then
138
138
  export. Pruning, programmatic seeding, and which commands write the lock:
139
139
  `llms/lock.md`.
140
140
 
@@ -147,19 +147,19 @@ environment and prints its URL.
147
147
  AND records — before importing. The blast radius is a disposable environment, not a
148
148
  production workspace, but confirm with the user before the first run.
149
149
 
150
- **Two destinations, and the choice changes more than the target.**
150
+ **One destination, and no flag for it.**
151
151
 
152
- - `--dest ephemeral` (DEFAULT) a NAMED, workspace-scoped, auto-expiring tenant
153
- (~1h; `--expires-hours` 1–72 at create time). The active one is tracked in
152
+ - `xanots deploy` writes to a NAMED, workspace-scoped, auto-expiring ephemeral tenant
153
+ (~1h; `--expires-hours` 1–72 at create time), and to nothing else an `--env` here
154
+ is a usage error, not a choice. The active env is tracked in
154
155
  `./.xano/ephemeral.json`, so deploying again REFRESHES it and the URL is unchanged;
155
156
  if it expired or was swept, a fresh one is created and the new URL is called out.
156
157
  `--static` puts the frontend ON THE EPHEMERAL, so backend and frontend share one
157
158
  disposable environment.
158
159
  ⚠ Only the BACKEND URL survives a refresh: the replace clears static hosting too,
159
160
  so `--static` publishes a NEW host every run and the previous URL stops serving.
160
- - `--dest sandbox` your single throwaway tenant, no expiry. `--static` puts the
161
- frontend on your OWN (parent) workspace instead, because the sandbox tenant does
162
- not serve static hosting.
161
+ - `xanots status` names the env this project last deployed to, its URL and its expiry,
162
+ without your having to remember which one it was.
163
163
  - `xanots release` promotes to your INSTANCE workspace and MERGES, not replaces:
164
164
  adds/updates what you define, deletes nothing, writes no rows. Destruction is
165
165
  opt-in per flag, previewed + confirmed, and can drop a table WITH its rows.
@@ -182,7 +182,7 @@ host falls back to '', and every call 404s off the dev server.
182
182
  ⚠ It is INJECTED in bracket form — `window["XANO_HOST"]="…"` — so verifying a deploy
183
183
  by grepping `window.XANO_HOST` matches nothing and reads as a failed inject. Grep the
184
184
  bare `XANO_HOST` token.
185
- ⚠ `xanots validate` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
185
+ ⚠ `xanots preflight` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
186
186
  `XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) from the environment.
187
187
  **Displaying a stored file.** A file column comes back as `{ path, name, type, size,
188
188
  meta, access, url }`. ⚠ Do NOT use its `url`: on a tenant-scoped environment that field
@@ -332,7 +332,7 @@ Non-obvious authoring rules:
332
332
  never removes it. The floor is the RUNTIME, not the def: a second def, or a much richer
333
333
  one, adds ~1 kB. So the cost is paid by importing ANY def at all, and reducing what a
334
334
  def does will not reduce it.
335
- Fix: `xanots paths <entry> --emit xano/routes.gen.ts` (`routes` is an accepted alias) — verbs, paths, and sockets as
335
+ Fix: `xanots routes <entry> --emit xano/routes.gen.ts` (`paths` is an accepted alias) — verbs, paths, and sockets as
336
336
  plain data importing NOTHING, still compile-checked: `routePath("blog/{slug}", { slug })`,
337
337
  `channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
338
338
  URLs lifted to `wss://h/ws/<tenant>:<canonical>`). A rename is a type error, not a 404.
@@ -358,7 +358,7 @@ Non-obvious authoring rules:
358
358
  `s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`
359
359
  take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);
360
360
  `s.group(body)` and `s.util.post_process(body)` take it **positionally**.
361
- `s.for` is **count-bounded** (`{ as, count, body }`), not from/to. See the
361
+ `s.for` is **count-bounded** (`{ as, count: <Value>, body }`), not from/to. See the
362
362
  authored signatures in `llms/statements-data.md`.
363
363
  - **MCP servers & agents are distinct root kinds** that both persist under the
364
364
  `toolset` payload key (so a same-name pair collides). `mcpServer({...})` exposes
@@ -419,19 +419,10 @@ Non-obvious authoring rules:
419
419
  credential the runner has. As a file that triple is `{ "type": "token",
420
420
  "instance_base_url": …, "workspace_id": <n>, "meta_api_token": … }`. The older
421
421
  `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works but ROTATES: single-use.
422
- - **Event-driven objects fire on an EPHEMERAL, not in the sandbox.** A `task`
423
- (scheduled), an `mcpServer`, and every trigger — `tableTrigger` included — run normally
424
- on an ephemeral env, which is `deploy`'s DEFAULT destination. So test an event-driven
425
- design (screen-on-insert, cron cleanup, MCP tool call) by deploying it and letting it
426
- run.
427
- ⚠ Under `--dest sandbox` they import cleanly but their stacks NEVER execute, and there
428
- is no way to fire one manually — an insert on a bound table does not run its
429
- `tableTrigger`, and the design silently does nothing. Only synchronously-invoked objects
430
- (queries, functions, and the agents an endpoint calls with `s.ai.agent.run`) run there.
431
- If you must stay on the sandbox, verify the logic out of band: factor the body into a
432
- `defineFunction` (or a callable `query`) and invoke it directly — a `tableTrigger` that
433
- screens a row on insert should delegate to a function a `query` can also call via
434
- `s.function.run`, and you assert against that.
422
+ - **Event-driven objects fire on an EPHEMERAL.** A `task` (scheduled), an `mcpServer`,
423
+ and every trigger — `tableTrigger` included — run normally on an ephemeral env, which
424
+ is where `deploy` sends them. So test an event-driven design (screen-on-insert, cron
425
+ cleanup, MCP tool call) by deploying it and letting it run.
435
426
  - **Zero-based numeric keys make `c.obj` a LIST.** `c.obj({ "0": "a" })` evaluates to
436
427
  `["a"]`: a numeric key IS an index in the engine's data model, so keys that are exactly
437
428
  `0..n-1` come back as a list with HTTP 200 and no error. Write `c.array([...])` when you
@@ -474,11 +465,11 @@ Control flow & blocks (each nests a sub-stack; block specials name it `body`):
474
465
  - **Every** statement takes `disabled?`/`description?` — 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 })`).
475
466
  - **Statements with an `as`** also take `asFilters?` — `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()]` → `number`); filters whose result the engine declares as `any` (`get`, `set`, `json_decode`, …) fold to `unknown`.
476
467
  - `s.conditional({ when, then, elif?, else? })` — 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[]`.
477
- - `s.for({ as, count, body })` — **count-bounded** loop (`as` is the index), NOT from/to.
468
+ - `s.for({ as, count, body })` — **count-bounded** loop (`as` is the index), NOT from/to; `count` is a `Value`, not a bare number.
478
469
  - `s.foreach({ as, list, body })` — iterate `list`; `as` is the current item.
479
470
  - `s.while({ when, body })` — `when` is a condition (`expr`/`cmp`/`and`/`or`).
480
471
  - `s.switch({ on, cases: [{ when, body, break? }], default? })` — multi-way branch on a subject `Value` `on`; each `case`'s `when` is a literal `Value` matched against `on` (NOT a comparison — use `s.conditional` for `<`/`>`/ranges). ⚠ **Omitting `break: true` FALLS THROUGH** — the matched case also runs every LATER case body. Type-checks clean; only `export --strict` catches it.
481
472
  - `s.try_catch({ try, catch?, finally? })` — three `Statement[]` blocks.
482
473
  - `s.group(body)` / `s.util.post_process(body)` — take a `Statement[]` **positionally**.
483
474
  - `s.foreach_break()` / `s.foreach_continue()` / `s.foreach_remove()` — nullary loop control.
484
- - `s.expect.to_throw({ body, exception? })` — `body` is the statements expected to raise.
475
+ - `s.expect.to_throw({ body, exception? })` — `body` is the statements expected to raise; `exception` is a `Value`, not a bare string.