@xanots/sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/dist/agent-file-refresh-7XCACU34.js +95 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +184 -0
- package/dist/capture-HUV5BNTC.js +32 -0
- package/dist/chunk-26CNBZE2.js +963 -0
- package/dist/chunk-33ND6LKO.js +78 -0
- package/dist/chunk-3DUAGYGP.js +1550 -0
- package/dist/chunk-3IGNIP6R.js +89 -0
- package/dist/chunk-47WDWMBJ.js +14 -0
- package/dist/chunk-4BXJGVZ3.js +318 -0
- package/dist/chunk-4HT3BNZ7.js +675 -0
- package/dist/chunk-4YMD2OOZ.js +23 -0
- package/dist/chunk-5WGEURVI.js +156 -0
- package/dist/chunk-5YTBTF25.js +29 -0
- package/dist/chunk-6DHBYBTO.js +166 -0
- package/dist/chunk-76QBEIGO.js +66 -0
- package/dist/chunk-7DKX2SPN.js +100 -0
- package/dist/chunk-7REDODS2.js +468 -0
- package/dist/chunk-C56BC2FY.js +254 -0
- package/dist/chunk-DGSF2Q5H.js +60 -0
- package/dist/chunk-EMCAWMHC.js +3982 -0
- package/dist/chunk-EZG76F7R.js +159 -0
- package/dist/chunk-FJ6ZS5KB.js +3607 -0
- package/dist/chunk-GNPVYOPB.js +17 -0
- package/dist/chunk-HJPTWBLH.js +38 -0
- package/dist/chunk-IZW6MK3U.js +248 -0
- package/dist/chunk-LH6Z6KFV.js +247 -0
- package/dist/chunk-MZUI3JNX.js +93 -0
- package/dist/chunk-NDZFBZHC.js +167 -0
- package/dist/chunk-NOO35BQ2.js +37 -0
- package/dist/chunk-NUQCEOKA.js +13 -0
- package/dist/chunk-QTNO2WD6.js +56 -0
- package/dist/chunk-RVB7ITKN.js +49 -0
- package/dist/chunk-TRU5OHQ5.js +6904 -0
- package/dist/chunk-VKFQJEMW.js +814 -0
- package/dist/chunk-VTIL47DT.js +125 -0
- package/dist/chunk-WHOJWOSV.js +112 -0
- package/dist/chunk-WKP4D5MO.js +1692 -0
- package/dist/chunk-X4DVXBFY.js +38 -0
- package/dist/chunk-XQ22GLYS.js +118 -0
- package/dist/chunk-YDZYMZBE.js +670 -0
- package/dist/chunk-YGPIY3WT.js +1146 -0
- package/dist/chunk-YYRDLFSJ.js +602 -0
- package/dist/chunk-ZUTSMMAG.js +46 -0
- package/dist/cli.d.ts +331 -0
- package/dist/cli.js +33 -0
- package/dist/codegen-command-OOKK6AZK.js +40 -0
- package/dist/codegen.d.ts +114 -0
- package/dist/codegen.js +71 -0
- package/dist/completion-TIPPZN7H.js +298 -0
- package/dist/config-NL33PN4D.js +84 -0
- package/dist/deploy-command-4EQ7XX5G.js +451 -0
- package/dist/env-target-IFJ5QDHC.js +16 -0
- package/dist/ephemeral-command-3X3I4ULC.js +242 -0
- package/dist/index.d.ts +4477 -0
- package/dist/index.js +300 -0
- package/dist/init-command-R6H7ZXW5.js +27 -0
- package/dist/internal.d.ts +74 -0
- package/dist/internal.js +2065 -0
- package/dist/io-AMIKRLPC.js +11 -0
- package/dist/live-diff-Y3AKETHE.js +196 -0
- package/dist/lock-3CVKALKT.js +37 -0
- package/dist/lock-commands-XTR2YKLY.js +232 -0
- package/dist/login-command-ACJF6KWQ.js +167 -0
- package/dist/logout-command-MX3MJS5U.js +68 -0
- package/dist/loop-OM3P2Z6O.js +93 -0
- package/dist/marketplace-command-RATXTKXN.js +379 -0
- package/dist/meta-client-OW5WKWW7.js +170 -0
- package/dist/node.d.ts +59 -0
- package/dist/node.js +442 -0
- package/dist/profile-command-SWJ3SPKR.js +77 -0
- package/dist/release-command-VV5UVZKL.js +427 -0
- package/dist/response-CVAE2kMj.d.ts +1915 -0
- package/dist/routes-manifest-SP3ZXLMR.js +244 -0
- package/dist/runtime-V4C3AC3A.js +13 -0
- package/dist/sandbox-details-command-HJE5SPVG.js +18 -0
- package/dist/sandbox-export-command-QCJY4GMV.js +24 -0
- package/dist/static-host-D6KS7X45.js +138 -0
- package/dist/store-BQ97nKIF.d.ts +7760 -0
- package/dist/test-command-TSPBQHBM.js +383 -0
- package/dist/validate-command-4ERHJSCO.js +122 -0
- package/dist/verify-rollout-FAVQ6EJY.js +40 -0
- package/dist/workspace-K72NP7SX.js +49 -0
- package/dist/workspace-command-OF23DPNI.js +156 -0
- package/dist/workspace-export-AJMGN3CQ.js +10 -0
- package/llms/fields.md +104 -0
- package/llms/filters.md +183 -0
- package/llms/kinds-agent-mcp.md +11 -0
- package/llms/kinds-core.md +68 -0
- package/llms/kinds-realtime.md +76 -0
- package/llms/lambda.md +111 -0
- package/llms/legacy.md +43 -0
- package/llms/object-kinds.md +31 -0
- package/llms/statements-calls.md +23 -0
- package/llms/statements-catalog.md +309 -0
- package/llms/statements-data.md +58 -0
- package/llms/statements-runtime.md +48 -0
- package/llms/tests.md +58 -0
- package/llms/triggers.md +22 -0
- package/llms/values.md +29 -0
- package/llms-full.txt +1672 -0
- package/llms.txt +483 -0
- package/manifest.json +9592 -0
- package/package.json +110 -0
package/llms-full.txt
ADDED
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
# xanots v0.0.1
|
|
2
|
+
|
|
3
|
+
> TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
|
|
4
|
+
|
|
5
|
+
Author a Xano workspace in TypeScript: build typed objects, register them on a
|
|
6
|
+
`new Xano()` instance, and call `.export()` to get one importable bundle. Compile
|
|
7
|
+
with `xanots export ./xano/index.js`.
|
|
8
|
+
|
|
9
|
+
Coverage: object kinds 24/30, statement surfaces 214/214, filters 226 (226 typed).
|
|
10
|
+
Not authorable here: branch, market_item, realtime_channel, run.job, run.service, tablemap — these cannot be authored and do not survive a pull; see `coverage.objectKinds.unmodeled` in `manifest.json`.
|
|
11
|
+
|
|
12
|
+
This file is the whole always-read surface: the mental model, the deploy contract,
|
|
13
|
+
every gotcha, and control flow. Per-surface detail lives in the topic files listed
|
|
14
|
+
below — open the one whose condition matches the task, and skip the rest. For
|
|
15
|
+
exhaustive per-entry detail in NEITHER — a statement's full field schema with engine
|
|
16
|
+
defaults, a filter's complete argument list, the engine `storedName` mapping — do a
|
|
17
|
+
TARGETED lookup in the shipped `manifest.json` (grep or `jq` the one entry you need;
|
|
18
|
+
it is ~55k tokens, so never read it whole). Its top-level keys are `name`, `version`, `description`, `coverage`, `values`, `objectKinds`, `fieldTypes`, `statements`, `filters`, `cli`, `cliGlobalFlags`. `statements` and `filters` are ARRAYS, not maps — SELECT, do not index:
|
|
19
|
+
jq '.statements[] | select(.sPath=="db.get")' manifest.json
|
|
20
|
+
jq '.filters[] | select(.name=="json_decode")' manifest.json
|
|
21
|
+
Select a statement on `sPath` (the `s.*` path you write), NOT `surface` (the XanoScript term): 24 of 214 differ — `var`→`set_var`, `break`→`foreach_break`, `foreach.remove`→`foreach_remove`, and every `expect.*`.
|
|
22
|
+
|
|
23
|
+
## Topic files
|
|
24
|
+
|
|
25
|
+
Each line is a condition on the task. Open the files whose condition matches and
|
|
26
|
+
skip the rest — paths are relative to this file (`node_modules/@xanots/sdk/` once
|
|
27
|
+
installed), so they resolve with a plain file read and always match the version you
|
|
28
|
+
have. `llms-full.txt` is this file and every one below concatenated — for a reader
|
|
29
|
+
that wants one fetch, not for an agent that can open the two files it needs.
|
|
30
|
+
|
|
31
|
+
- [Object kinds](llms/object-kinds.md): Read when you need the full catalog of authorable primitives — which factory builds each, which register method takes it, and which payload key it lands under. Read it too when a `register*` call will not typecheck because the array was built by `.flatMap()`/`.concat()` across modules.
|
|
32
|
+
- [Core def shapes](llms/kinds-core.md): Read when authoring a function, query, api group, task, workflow test, middleware, or tool — and for the `response` and `expr` shapes every one of them uses.
|
|
33
|
+
- [Saved unit tests, assertions, and mocks](llms/tests.md): Read when authoring a `workflowTest()` stack, when a query/function/middleware carries `tests`, when a statement should mock a value, or when running a deployed environment's tests.
|
|
34
|
+
- [Agent and MCP def shapes](llms/kinds-agent-mcp.md): Read when the workspace defines an `agent()` or an `mcpServer()`.
|
|
35
|
+
- [Realtime def shapes](llms/kinds-realtime.md): Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.
|
|
36
|
+
- [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.
|
|
37
|
+
- [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`).
|
|
38
|
+
- [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.
|
|
39
|
+
- [Auth, cross-object calls, and microservices](llms/statements-calls.md): Read when the stack authenticates, calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), or reaches a microservice.
|
|
40
|
+
- [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.
|
|
41
|
+
- [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.
|
|
42
|
+
- [Filter catalog](llms/filters.md): Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
|
|
43
|
+
- [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, each surface binds a different set of identifiers, and the hazards are not guessable.
|
|
44
|
+
- [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. None of the retired names are authorable for new code.
|
|
45
|
+
- [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.
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
Authoring is **declarative def-objects** passed to factories — there is no
|
|
50
|
+
callback/chaining builder.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { workspace, apiGroup, query, table, input, f, ref, inp, auth, s } from "@xanots/sdk";
|
|
54
|
+
|
|
55
|
+
const users = table({
|
|
56
|
+
name: "users",
|
|
57
|
+
auth: true, // backs authentication
|
|
58
|
+
// `id` (int PK) + `created_at` (epochms) are auto-injected — don't declare them.
|
|
59
|
+
schema: {
|
|
60
|
+
email: f.email({ required: true }),
|
|
61
|
+
name: f.text(),
|
|
62
|
+
},
|
|
63
|
+
// Indexes: { type, fields: [{ name, op? }] }. `"unique"` is shorthand for `"btree|unique"`.
|
|
64
|
+
// A DOTTED name indexes a key inside a column (`xdo.email`); only the part before the
|
|
65
|
+
// first dot is checked against the schema.
|
|
66
|
+
index: [{ type: "unique", fields: [{ name: "email" }] }],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const posts = table({
|
|
70
|
+
name: "posts",
|
|
71
|
+
schema: {
|
|
72
|
+
author: f.tableRef(users), // foreign key → users (NOT `ref`)
|
|
73
|
+
body: f.text({ required: true }),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const api = apiGroup({ name: "blog", canonical: "blog" }); // canonical → the URL token
|
|
78
|
+
|
|
79
|
+
const createPost = query({
|
|
80
|
+
name: "create_post", verb: "POST", apiGroup: api, auth: users, // the auth table
|
|
81
|
+
input: { body: input.text({ required: true }) },
|
|
82
|
+
stack: [
|
|
83
|
+
s.db.add({ table: posts, row: { author: auth("id"), body: inp("body") }, as: "post" }),
|
|
84
|
+
],
|
|
85
|
+
response: ref("post"),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const listPosts = query({
|
|
89
|
+
name: "list_posts", verb: "GET", apiGroup: api,
|
|
90
|
+
stack: [s.db.query({ table: posts, sort: [{ sortBy: "created_at", dir: "desc" }], as: "rows" })],
|
|
91
|
+
response: ref("rows"),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export default workspace("my-blog")
|
|
95
|
+
.registerTables([users, posts])
|
|
96
|
+
.registerApiGroups([api])
|
|
97
|
+
.registerQueries([createPost, listPosts]);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Compile: `xanots export ./index.ts --out bundle.json` (or `writeBundle(app, path)`
|
|
101
|
+
from `@xanots/sdk/node` in code). The default export must be the `Xano` registry. The entry must be an
|
|
102
|
+
ES module (XanoTS defs are ESM-only): set `"type": "module"` in the nearest
|
|
103
|
+
package.json or name the entry `.mts`. `npm init -y` writes `"type": "commonjs"`,
|
|
104
|
+
which fails with a "must be ES modules" error until you switch it to module.
|
|
105
|
+
|
|
106
|
+
Set `canonical` on every `apiGroup`. The engine mints the URL token server-side, so
|
|
107
|
+
without one a group's client paths are unresolvable until a lock exists: the bundle
|
|
108
|
+
exports fine and `xanots paths` / `getPath()` then fail on the very queries it just
|
|
109
|
+
built. An explicit `canonical` resolves them from the source alone.
|
|
110
|
+
|
|
111
|
+
Build warnings: `export()` prints the shapes that deploy clean and then do the wrong
|
|
112
|
+
thing (a `bulk.update` zero-filling omitted columns, an `ignoreEmpty` on an empty
|
|
113
|
+
operand, a `ref()` nothing binds). Each has a legitimate use, so each only warns —
|
|
114
|
+
but nothing fails on a message no one reads, so in CI and in unattended agent builds
|
|
115
|
+
pass `--strict` (`emitBundle(app, { strict: true })` / `app.export({ strict: true })`):
|
|
116
|
+
every warning becomes a hard failure. Same bundle bytes either way.
|
|
117
|
+
|
|
118
|
+
Identity: object guids derive from `(type, name)`, so renames change identity.
|
|
119
|
+
`xanots export <entry> --lock` freezes every guid + api-group/toolset canonical in a lock
|
|
120
|
+
file written BESIDE THE ENTRY FILE — `xano/xano.lock` for the standard scaffold, NOT the
|
|
121
|
+
project root — which you commit (auto-read once present; CI guard `--frozen-lock` fails
|
|
122
|
+
instead of changing the lock). Fix-up subcommands:
|
|
123
|
+
`xanots lock rename <kind> <old> <new>` (kind = payloadKey or table/api_group),
|
|
124
|
+
`xanots lock prune <entry-file> [keys…] --yes` — finding orphans RUNS the entry's module scope
|
|
125
|
+
(env assertions included); `--no-verify --yes <kind:name>…` prunes named keys with no evaluation
|
|
126
|
+
and no orphan check,
|
|
127
|
+
`xanots lock adopt <live-bundle.json> [--yes]` — all accept `--lock=<path>`.
|
|
128
|
+
Programmatic use: call `seedLockOverrides(readLockFile(path))` BEFORE importing
|
|
129
|
+
any def module — references bake guids at import time, so late seeding is a
|
|
130
|
+
silent no-op (`resetLockOverrides` exists for tests).
|
|
131
|
+
Development workflow: opt in EARLY — run `xanots export ./xano/index.ts --lock` once and COMMIT
|
|
132
|
+
`xano.lock` beside the entry file; every later export then keeps identities
|
|
133
|
+
stable across renames and environments. To rename an object: rename in code,
|
|
134
|
+
export (stderr prints the exact fix-up), run `xanots lock rename <kind> <old>
|
|
135
|
+
<new>`, export again — the original guid is emitted under the new name, so the
|
|
136
|
+
engine renames in place instead of delete+create. Taking over an existing
|
|
137
|
+
workspace: `xanots lock adopt <its-packageExport.json>` first, then export.
|
|
138
|
+
|
|
139
|
+
## Deploy
|
|
140
|
+
|
|
141
|
+
`xanots init` → `xanots deploy` → URL. Each `deploy` runs the same compile
|
|
142
|
+
pipeline as `export` (honoring `xano.lock`), then imports the result into a live
|
|
143
|
+
environment and prints its URL.
|
|
144
|
+
⚠ Every deploy is a FULL REPLACE: it clears that environment's workspace — objects
|
|
145
|
+
AND records — before importing. The blast radius is a disposable environment, not a
|
|
146
|
+
production workspace, but confirm with the user before the first run.
|
|
147
|
+
|
|
148
|
+
**Two destinations, and the choice changes more than the target.**
|
|
149
|
+
|
|
150
|
+
- `--dest ephemeral` (DEFAULT) — a NAMED, workspace-scoped, auto-expiring tenant
|
|
151
|
+
(~1h; `--expires-hours` 1–72 at create time). The active one is tracked in
|
|
152
|
+
`./.xano/ephemeral.json`, so deploying again REFRESHES it and the URL is unchanged;
|
|
153
|
+
if it expired or was swept, a fresh one is created and the new URL is called out.
|
|
154
|
+
`--static` puts the frontend ON THE EPHEMERAL, so backend and frontend share one
|
|
155
|
+
disposable environment.
|
|
156
|
+
⚠ Only the BACKEND URL survives a refresh: the replace clears static hosting too,
|
|
157
|
+
so `--static` publishes a NEW host every run and the previous URL stops serving.
|
|
158
|
+
- `--dest sandbox` — your single throwaway tenant, no expiry. `--static` puts the
|
|
159
|
+
frontend on your OWN (parent) workspace instead, because the sandbox tenant does
|
|
160
|
+
not serve static hosting.
|
|
161
|
+
- `xanots release` promotes to your INSTANCE workspace and MERGES, not replaces:
|
|
162
|
+
adds/updates what you define, deletes nothing, writes no rows. Destruction is
|
|
163
|
+
opt-in per flag, previewed + confirmed, and can drop a table WITH its rows.
|
|
164
|
+
NOT flag-gated: removing a column from a schema destroys that column's data
|
|
165
|
+
(previewed against live). `--prune` deletes only what `xano.lock` records this
|
|
166
|
+
project released, and refuses without a lock.
|
|
167
|
+
|
|
168
|
+
Nothing from a DEPLOY is written back into `xano.lock` (an ephemeral/sandbox is a
|
|
169
|
+
separate workspace, so its identities must not pollute yours). The one write-back is
|
|
170
|
+
`release --replace`, which mints fresh identities in the workspace the lock describes:
|
|
171
|
+
it re-pins the lock from the rebuilt workspace, because otherwise the next release
|
|
172
|
+
matches nothing and duplicates every object. Deploying an ENTRY
|
|
173
|
+
FILE still updates the local lock via the shared compile step, exactly as `export`
|
|
174
|
+
does — only when a lock exists or `--lock` is passed.
|
|
175
|
+
|
|
176
|
+
**Frontend wiring.** `--static <dir>` injects the DEPLOYED env's backend URL as
|
|
177
|
+
`window.XANO_HOST` into EVERY html document in the build, before the app bundle runs,
|
|
178
|
+
so the frontend needs no rebuild to target an env. Every document, not just the root:
|
|
179
|
+
a prerendered build serves a different one per route, and a route without the global
|
|
180
|
+
renders fine while every call goes to the wrong origin. Read it at runtime with a build-time
|
|
181
|
+
fallback:
|
|
182
|
+
const HOST = (typeof window !== 'undefined' && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
|
|
183
|
+
In LOCAL DEV there is no injected global, so the fallback is what answers: set
|
|
184
|
+
`VITE_XANO_HOST` in a `.env.local` beside `.env.example` at the PROJECT ROOT. The
|
|
185
|
+
scaffold's vite config sets `envDir` there (its `root` is `frontend/`, and Vite
|
|
186
|
+
resolves `.env` files against `root`) — without it the var reads as undefined, the
|
|
187
|
+
host falls back to '', and every call 404s off the dev server.
|
|
188
|
+
⚠ It is INJECTED in bracket form — `window["XANO_HOST"]="…"` — so verifying a deploy
|
|
189
|
+
by grepping `window.XANO_HOST` matches nothing and reads as a failed inject. Grep the
|
|
190
|
+
bare `XANO_HOST` token.
|
|
191
|
+
⚠ `xanots validate` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
|
|
192
|
+
`XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) from the environment.
|
|
193
|
+
**Displaying a stored file.** A file column comes back as `{ path, name, type, size,
|
|
194
|
+
meta, access, url }`. ⚠ Do NOT use its `url`: on a tenant-scoped environment that field
|
|
195
|
+
addresses the instance host WITHOUT the `/tenant/<name>` segment and 404s, silently —
|
|
196
|
+
as a broken `<img>`, with every API assertion still passing. Build the URL from `path`
|
|
197
|
+
instead: `fileUrl(row.avatar, HOST)` (exported from `@xanots/sdk`) returns `null` for
|
|
198
|
+
an absent file and is correct on an ephemeral and an instance workspace alike.
|
|
199
|
+
⚠ A static host serves these files verbatim, so everything injected is PUBLIC — base
|
|
200
|
+
URLs and publishable keys only, never secrets. Secrets go in backend env, read
|
|
201
|
+
server-side via `env(name)`.
|
|
202
|
+
|
|
203
|
+
**Full CLI surface:** `xanots <command> --help` lists every command, flag, and
|
|
204
|
+
default; the shipped `manifest.json` carries the same in its `cli` array. This doc
|
|
205
|
+
does not duplicate it — it covers what you must know to AUTHOR a workspace.
|
|
206
|
+
**Recommended style:** reach statements through the `s` namespace
|
|
207
|
+
(`s.db.add`, `s.math.add`, …) — one discoverable, tab-completable surface. The
|
|
208
|
+
flat factory aliases (`dbAdd`, `dbQuery`, `setVar`, `mathAdd`, …) are exported
|
|
209
|
+
and identical in output; prefer `s.*` in new code so examples stay consistent.
|
|
210
|
+
|
|
211
|
+
## Gotchas
|
|
212
|
+
|
|
213
|
+
Non-obvious authoring rules:
|
|
214
|
+
|
|
215
|
+
- **No callback builder.** Flat def-objects + `register*`, not
|
|
216
|
+
`workspace(w => w.table(...))`. `workspace(name)` returns a named `new Xano()`;
|
|
217
|
+
tables are `table({ schema: { col: f.text() } })`.
|
|
218
|
+
- **Foreign key is `f.tableRef(table)`, not `ref`.** `ref(name)` references a
|
|
219
|
+
stack variable (a value); `f.tableRef` is the column constructor.
|
|
220
|
+
- **Reference-helper picker:** `ref` = stack var (`as:` output), `inp` = input,
|
|
221
|
+
`col` = table column (in `db.query` `where`), `auth("id")` = the caller,
|
|
222
|
+
`c.*` = a constant. Pick by what you're pointing at.
|
|
223
|
+
- **`s.api.call` / `s.task.call` / `s.trigger.call` / `s.workflow_test.call` are
|
|
224
|
+
WORKFLOW-TEST ONLY.** Outside a `workflowTest({...})` stack the engine cannot reach
|
|
225
|
+
the target, so one in a query/function/task deploys clean and then answers the first
|
|
226
|
+
real request with `ERROR_FATAL: <Type> does not exist: <type>:<n>` — and not per
|
|
227
|
+
host kind, the same call fails identically from a function a query runs.
|
|
228
|
+
`export()` refuses them. `s.function.call`, `s.tool.call`, `s.middleware.call` and
|
|
229
|
+
`s.addon.call` run anywhere, as does `s.function.run` (the ordinary function
|
|
230
|
+
invocation). To share logic between endpoints, put it in a `defineFunction` and
|
|
231
|
+
`s.function.run` it from both.
|
|
232
|
+
- **`expect.*` is a unit-test assertion; `s.expect.*` is a workflow-test statement.**
|
|
233
|
+
A statement's `mock` is keyed by TEST NAME; an undeclared name throws. See `llms/tests.md`.
|
|
234
|
+
- **Drilling into a maybe-null `db.get` result 500s — use `ref(path, { safe: true })`.**
|
|
235
|
+
`db.get` binds `null` on a no-match, but a nested `ref("owner.user_id")` resolves
|
|
236
|
+
`$owner.user_id` in one lookup and raises a runtime "Unable to locate var" (HTTP 500)
|
|
237
|
+
when `owner` is null — so an ownership/existence guard throws instead of failing
|
|
238
|
+
cleanly. Either guard existence first (`expr(ref("owner"), "!=", c.null())`,
|
|
239
|
+
or a `db.has`/`db.query`-count precondition), or drill with the null-safe opt-in:
|
|
240
|
+
`expr(ref("owner.user_id", { safe: true }), "=", auth("id"))` compiles through the
|
|
241
|
+
`get` filter and yields `null` (guard reads `false`) rather than 500ing.
|
|
242
|
+
Works inside `obj({...})` too — no per-member `s.set_var` hoist needed.
|
|
243
|
+
⚠ EXPRESSION and `obj()` operands ONLY — never a `db.*` match argument. `null`
|
|
244
|
+
is not a legal `fieldValue`/`id`, so a chained get (fetch the child row, then
|
|
245
|
+
fetch its parent to check the owner) fails with HTTP 400 `Missing param:
|
|
246
|
+
field_value` one statement BEFORE the guard — the same net effect as the 500,
|
|
247
|
+
with an internal-sounding status the caller should never see. Only an id
|
|
248
|
+
matching no row reaches it, so it deploys clean. In the chained case guarding
|
|
249
|
+
existence first is MANDATORY, not a stylistic alternative: precondition on
|
|
250
|
+
`expr(ref("ticket"), "!=", c.null())`, then drill WITHOUT `safe` (the base is
|
|
251
|
+
known non-null by then). `export()` warns on a safe ref in that position.
|
|
252
|
+
⚠ That is for a base that EXISTS and may be null. If the base binds nothing at all
|
|
253
|
+
(a typo), `{ safe: true }` turns the 500 into a silent `null` and buries the bug —
|
|
254
|
+
`export()` warns on a `ref()` whose base segment no `as` in that stack binds, with a
|
|
255
|
+
did-you-mean; fix the name instead.
|
|
256
|
+
- **DB reads are field-match, not `where`-expr.** `db.get`/`db.edit`/`db.del`/
|
|
257
|
+
`db.has`/`db.patch` match one field: `{ fieldName, fieldValue }` (`fieldName`
|
|
258
|
+
defaults to the PK `id`). Only `db.query` takes a `where`/`additionalWhere`
|
|
259
|
+
`expr(...)`; writes (`db.add`/`db.edit`/`db.add_or_edit`) take a `row`/`data`.
|
|
260
|
+
`db.get_by_id` is the exception to the field-match rule — it is the dedicated
|
|
261
|
+
get-by-primary-key statement and takes `{ table, id }`, NOT `fieldValue`.
|
|
262
|
+
Signatures: `llms/statements-data.md`.
|
|
263
|
+
- **Single-field only — no composite match.** These ops match exactly ONE field;
|
|
264
|
+
there is no two-field form (the engine's by-field lookup takes a single
|
|
265
|
+
predicate). For a `(a, b)` existence/fetch — e.g. dedupe a `(habit, date)`
|
|
266
|
+
check-in — use `db.query({ where: [expr(col("habit"), "=", ...), expr(col("date"), "=", ...)], as })`
|
|
267
|
+
(a `where` array is ANDed) and branch on the result, rather than pushing the
|
|
268
|
+
check to the client.
|
|
269
|
+
- **System columns are auto-injected.** `id` + `created_at` are prepended to
|
|
270
|
+
every table (`system: true` by default); declaring them by hand is redundant.
|
|
271
|
+
`id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.
|
|
272
|
+
Both are valid targets wherever a column name is accepted — `db.query` `sort`/
|
|
273
|
+
`output`, a `db.get`/`edit`/`del` `fieldName`, etc. (the column-name type is
|
|
274
|
+
`keyof schema | "id" | "created_at"`), and both appear in `InferRow<typeof table>`.
|
|
275
|
+
- **Seed a table's starting rows with `table({ seed })`.** `seed` takes rows
|
|
276
|
+
typed against the table's schema as a WRITE shape (a column without
|
|
277
|
+
`required: true`, and the system columns, may be omitted; `null` needs
|
|
278
|
+
`nullable: true`) — inline (`seed: [{ name: "…" }]`), a FILE
|
|
279
|
+
(`seed: seedFile("./seed.json", import.meta.url)`; path resolves against the DECLARING
|
|
280
|
+
file), or a thunk (`seed: () => import("./seed.json")`, async ok, `.default` unwrapped).
|
|
281
|
+
Inline rows are TYPED against the schema at compile time; a `seedFile`/thunk seed is
|
|
282
|
+
NOT — the JSON is opaque to the typechecker, and the `xanots export`/`deploy` CLI
|
|
283
|
+
validates it instead, naming the row index, the offending column, and the known
|
|
284
|
+
columns. ⚠ Prefer `seedFile` for a file: a thunk's `import()` sits in
|
|
285
|
+
YOUR module, so a bundler emits the JSON as a served chunk. NEITHER is a secrecy
|
|
286
|
+
boundary — a bundler that inlines the asset ships them either way. Never put secrets
|
|
287
|
+
in `seed`. `deploy --static` REFUSES a build carrying internal/sensitive seed values;
|
|
288
|
+
`--allow-seed-in-static` overrides for deliberately public demo credentials.
|
|
289
|
+
Deploy is a full replace, so re-deploying re-seeds
|
|
290
|
+
cleanly (no duplication). Omit `id` and rows auto-number `1..N` (int PK) or take
|
|
291
|
+
a stable derived uuid (uuid PK); supplying `id` pins it (engine preserves it,
|
|
292
|
+
resets an int sequence past the max). All-or-nothing — mixing explicit and
|
|
293
|
+
omitted `id` throws. A `system:false` PK is the author's to supply. Pinning is
|
|
294
|
+
`seed`-only — `s.db.bulk.add` DROPS `id` unless `allowIdField: true`.
|
|
295
|
+
- **`use_xdo` storage mode.** Workspace setting (`registerWorkspace({ use_xdo })`,
|
|
296
|
+
default `false`) controlling whether fields are stored as JSON under the `xdo`
|
|
297
|
+
column (`true`, adds a `gin(xdo)` index) or as real columns (`false`, no gin).
|
|
298
|
+
Tables inherit it; override per-table with `table({ useXdo })`. Resolved at
|
|
299
|
+
`export()`, so the workspace and tables can be registered in any order.
|
|
300
|
+
- **Self-referencing tables** need the bare-name form: inside `tweets`'s own
|
|
301
|
+
schema, write `f.tableRef("tweets", { type: "int" })` — the `const tweets`
|
|
302
|
+
handle isn't assigned yet, so the handle form throws "used before declaration".
|
|
303
|
+
- **Same-name siblings collide.** Object guids derive from `(type, name)` and
|
|
304
|
+
ignore a query's `verb`, so a `GET` and `POST` both named `posts` clash;
|
|
305
|
+
`export()` throws on the collision — give them DISTINCT names (`posts` and
|
|
306
|
+
`posts/create`; the verb still carries the semantics). A distinct explicit
|
|
307
|
+
`guid` on each clears that throw but is NOT a lasting fix: a lock entry is
|
|
308
|
+
keyed by `(type, name)` too and cannot hold two guids, so `export --lock`
|
|
309
|
+
refuses the pair — and a committed lock is the recommended workflow.
|
|
310
|
+
`export()` warns on a same-name pair even unlocked. An explicit `guid` is
|
|
311
|
+
for pinning identity across a rename, not for sharing a name.
|
|
312
|
+
- **`export()` vs `emitBundle()` vs `writeBundle()`:** `writeBundle(app, path)`
|
|
313
|
+
writes it to disk; `export()` returns the bundle object;
|
|
314
|
+
`emitBundle()` returns the pretty JSON string. All three run the SAME build-time
|
|
315
|
+
checks, INCLUDING seed validation of a literal `seed: [...]` array (row shape,
|
|
316
|
+
unknown column, coercion, enum membership, the all-or-nothing `id` rule). A
|
|
317
|
+
DEFERRED seed — a thunk (`() => import("./seed.json")`) or `seedFile()` — needs an
|
|
318
|
+
await or the filesystem, so it is materialised and checked only by the
|
|
319
|
+
`xanots export`/`deploy` CLI path. Seed rows are never emitted into the bundle
|
|
320
|
+
either way; only `deploy` ships them. The `node:fs` writers
|
|
321
|
+
(`writeBundle`/`writeArtifact`) and lock-file I/O import from `@xanots/sdk/node`,
|
|
322
|
+
NOT the browser-safe `@xanots/sdk` entry (which a frontend can import query
|
|
323
|
+
defs from to use `getPath()`/`InferInput` with no Node built-ins in the bundle).
|
|
324
|
+
The compiler machinery (per-kind `encode*`, the registries, the bundle serializer,
|
|
325
|
+
the lock model) is on `@xanots/sdk/internal` and is never needed to author.
|
|
326
|
+
- **Client bundle size / tree-shaking.** `@xanots/sdk` is `sideEffects: false` and pulls
|
|
327
|
+
no Node built-ins, so a bundler drops unused SDK exports. But importing a **def** for its
|
|
328
|
+
`getPath()`/`verb`/`getUrl()`/`getChannel()` also pulls whatever its `stack` references:
|
|
329
|
+
the `s.*`/`c.*` factory CALLS run at module load to BUILD it. Types are free.
|
|
330
|
+
⚠ A FLOOR — **~289 kB minified (~57 kB gzipped)** for the FIRST def; splitting modules
|
|
331
|
+
never removes it. The floor is the RUNTIME, not the def: a second def, or a much richer
|
|
332
|
+
one, adds ~1 kB. So the cost is paid by importing ANY def at all, and reducing what a
|
|
333
|
+
def does will not reduce it.
|
|
334
|
+
Fix: `xanots paths <entry> --emit xano/routes.gen.ts` (`routes` is an accepted alias) — verbs, paths, and sockets as
|
|
335
|
+
plain data importing NOTHING, still compile-checked: `routePath("blog/{slug}", { slug })`,
|
|
336
|
+
`channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
|
|
337
|
+
URLs lifted to `wss://h/ws/<tenant>:<canonical>`). A rename is a type error, not a 404.
|
|
338
|
+
- **Intra-workspace imports use `.js` specifiers** (`../tables/links.js`), not
|
|
339
|
+
extensionless — the defs compile under `moduleResolution: bundler`. Add the `.js`.
|
|
340
|
+
- **Verifying a def outside a bundler.** Inside a bundler (Vite/webpack) importing a
|
|
341
|
+
query def to read `getPath()`/`verb` works directly. To spot-check from Node, run a REAL
|
|
342
|
+
file with `tsx <file.ts>` **from inside the project root** — not `tsx -e "import …"`
|
|
343
|
+
(its CJS-preparse mis-resolves the package `exports` map → ERR_PACKAGE_PATH_NOT_EXPORTED),
|
|
344
|
+
and not bare `node file.ts` (chokes on the `.js`-specifier intra-workspace imports the
|
|
345
|
+
xanots CLI's own loader resolves). Running from outside the project root also breaks
|
|
346
|
+
the `@xanots/sdk` specifier resolution.
|
|
347
|
+
- **A helper returning `Statement[]` widens the stack and kills `InferResponse`.**
|
|
348
|
+
Factoring a repeated guard into `function requireX(): Statement[]` and spreading it
|
|
349
|
+
(`...requireX("d")`) drops the stack's TUPLE type, so EVERY `ref()`/`as` in that stack —
|
|
350
|
+
including ones declared after the spread — resolves to `unknown` and the query's
|
|
351
|
+
response types as `StackTupleWidened`. Nothing fails at the helper: the error surfaces
|
|
352
|
+
wherever the response is consumed, often a frontend package's typecheck. Fix: return
|
|
353
|
+
`statements(s.a(...), s.b(...))` — a const-generic identity export whose tuple survives
|
|
354
|
+
the spread. A helper that builds its array in a LOOP cannot be a tuple; declare
|
|
355
|
+
`responseShape` on the query there. See `llms/statements-runtime.md`.
|
|
356
|
+
- **Block specials nest a `body`, not a `stack`.** `s.for`/`s.foreach`/
|
|
357
|
+
`s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`
|
|
358
|
+
take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);
|
|
359
|
+
`s.group(body)` and `s.util.post_process(body)` take it **positionally**.
|
|
360
|
+
`s.for` is **count-bounded** (`{ as, count, body }`), not from/to. See the
|
|
361
|
+
authored signatures in `llms/statements-data.md`.
|
|
362
|
+
- **MCP servers & agents are distinct root kinds** that both persist under the
|
|
363
|
+
`toolset` payload key (so a same-name pair collides). `mcpServer({...})` exposes
|
|
364
|
+
tools over MCP (auth is per-tool — no server-level gate); `agent({...})` carries a
|
|
365
|
+
typed `llm` block — and so may an `mcpServer`, since the two are ONE stored
|
|
366
|
+
object distinguished by `type`. Their `tools` take a `tool()` handle (or name), resolved to the
|
|
367
|
+
tool's guid like the call family; a raw numeric `id` is an escape hatch.
|
|
368
|
+
- **`task.schedule` is an array** of `{ startsOn, freq?, repeatEnabled?, endsOn?, endsEnabled? }`
|
|
369
|
+
(`ScheduleDef[]`), not a single `{ type, value }`. `freq` is seconds; `startsOn`/
|
|
370
|
+
`endsOn` are timestamp strings validated at encode time — `"2026-01-01T00:00:00Z"`, or
|
|
371
|
+
the space-separated `"2026-01-01 00:00:00+0000"` a pulled workspace carries. Never
|
|
372
|
+
epoch numbers, and never zoneless: the engine stores the string verbatim without
|
|
373
|
+
checking it, so one it cannot parse deploys clean and the task then never fires.
|
|
374
|
+
- **`get_input`/`get_raw_input` read the whole payload**, not one named input
|
|
375
|
+
(args are `{ as?, encoding?, excludeMiddleware? }` — no `name`). For a single
|
|
376
|
+
input use `inp("name")`.
|
|
377
|
+
- **`f.password()` defaults to `access: "internal"`, so `db.get` does NOT return it.**
|
|
378
|
+
A login stack that reads `ref("u.password")` after a plain `db.get` fails at runtime
|
|
379
|
+
with `Unable to locate var: u.password` — the column is simply absent from the row.
|
|
380
|
+
Name it in the read's `output` to pull it: `s.db.get({ table: users, fieldName: "email",
|
|
381
|
+
fieldValue: inp("email"), output: ["id", "email", "password"], as: "u" })`, then
|
|
382
|
+
`s.security.check_password`. `output` OVERRIDES column visibility — it is the only way to
|
|
383
|
+
read an `internal` column, and `export()` warns when a stack reads one a `db.get` did not
|
|
384
|
+
return.
|
|
385
|
+
- **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**
|
|
386
|
+
The regex filters (`regex_test`/`regex_match`/`regex_replace`/…) are pattern-piped
|
|
387
|
+
PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped. A bare
|
|
388
|
+
`c.text("^[^@\s]+@...$")` is an invalid pattern that matches *nothing* for every
|
|
389
|
+
input, so a precondition on it silently rejects all values (valid ones included).
|
|
390
|
+
`c.regex("^[^@\s]+@[^@\s]+\.[^@\s]+$", "i")` wraps + escapes it for you (accepts a
|
|
391
|
+
JS `RegExp` too: `c.regex(/^…$/i)`), and `withFilters` throws on a bare `c.text`
|
|
392
|
+
pattern pointing here — from ANY position in the chain, so a normalizer in front
|
|
393
|
+
(`withFilters(c.text("^hi"), fl.trim(), fl.regex_test(...))`) is refused too; nothing
|
|
394
|
+
upstream adds the delimiters. `s.expect.to_match`'s `value` is the same PATTERN slot,
|
|
395
|
+
refused the same way; a `ref`/`inp` pattern is passed through untouched, either place.
|
|
396
|
+
Better still, prefer a native typed input
|
|
397
|
+
(`input.email({...})`) over hand-rolled validation.
|
|
398
|
+
- **Compose a rule set as SIBLINGS, not a folded chain.** `and(...rules)` takes any
|
|
399
|
+
number of terms and encodes flat; `rules.reduce((acc, r) => and(acc, r))` nests one
|
|
400
|
+
container per rule, which costs quadratic bytes (512 terms: 394 KiB flat, 21 MiB
|
|
401
|
+
folded) and is refused past 128 levels. Mixed joins: `and(or(...anyOf), ...allOf)`.
|
|
402
|
+
- **Declare inputs with `input.<type>()`, read them with `inp("name")`.** `inp()`
|
|
403
|
+
resolves ONLY against this def's own `input` block — a value produced earlier in
|
|
404
|
+
the stack is `ref("var.field")`, not `inp("field")`. An undeclared name fails at
|
|
405
|
+
runtime with `Unable to locate input` (`export()` warns).
|
|
406
|
+
- **Don't take a password through `input.password` on login — it double-hashes.**
|
|
407
|
+
An `f.password()` column hashes on write, and `input.password` *also* hashes the
|
|
408
|
+
submission on bind, so `s.security.check_password` compares two different hashes
|
|
409
|
+
and a correct password always fails (`ok:false` on a found row). Take the submitted
|
|
410
|
+
password as `input.text()` on both signup and login and pass the plaintext straight
|
|
411
|
+
to `check_password` (which does the comparison hash itself).
|
|
412
|
+
- **Agents authenticate with env vars — never `xanots login`.** `login` blocks on a
|
|
413
|
+
browser consent no agent can complete. Set `$XANO_INSTANCE_URL` + `$XANO_WORKSPACE_ID`
|
|
414
|
+
+ `$XANO_META_TOKEN` and run `deploy`/`release` directly: no disk, no rotation, so it
|
|
415
|
+
survives repeated runs. It outranks every other credential (`--config` and
|
|
416
|
+
`$XANO_REFRESH_TOKEN` included, each named on stderr when displaced), and setting SOME
|
|
417
|
+
of the three is a hard error — a misspelled secret must not fall through to whatever
|
|
418
|
+
credential the runner has. As a file that triple is `{ "type": "token",
|
|
419
|
+
"instance_base_url": …, "workspace_id": <n>, "meta_api_token": … }`. The older
|
|
420
|
+
`$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works but ROTATES: single-use.
|
|
421
|
+
- **Event-driven objects fire on an EPHEMERAL, not in the sandbox.** A `task`
|
|
422
|
+
(scheduled), an `mcpServer`, and every trigger — `tableTrigger` included — run normally
|
|
423
|
+
on an ephemeral env, which is `deploy`'s DEFAULT destination. So test an event-driven
|
|
424
|
+
design (screen-on-insert, cron cleanup, MCP tool call) by deploying it and letting it
|
|
425
|
+
run.
|
|
426
|
+
⚠ Under `--dest sandbox` they import cleanly but their stacks NEVER execute, and there
|
|
427
|
+
is no way to fire one manually — an insert on a bound table does not run its
|
|
428
|
+
`tableTrigger`, and the design silently does nothing. Only synchronously-invoked objects
|
|
429
|
+
(queries, functions, and the agents an endpoint calls with `s.ai.agent.run`) run there.
|
|
430
|
+
If you must stay on the sandbox, verify the logic out of band: factor the body into a
|
|
431
|
+
`defineFunction` (or a callable `query`) and invoke it directly — a `tableTrigger` that
|
|
432
|
+
screens a row on insert should delegate to a function a `query` can also call via
|
|
433
|
+
`s.function.run`, and you assert against that.
|
|
434
|
+
- **Zero-based numeric keys make `c.obj` a LIST.** `c.obj({ "0": "a" })` evaluates to
|
|
435
|
+
`["a"]`: a numeric key IS an index in the engine's data model, so keys that are exactly
|
|
436
|
+
`0..n-1` come back as a list with HTTP 200 and no error. Write `c.array([...])` when you
|
|
437
|
+
want the list, or prefix the keys (`"k0"`) when you want an object. A non-zero-based
|
|
438
|
+
numeric key (`{ "2": … }`) survives as a key. Export warns.
|
|
439
|
+
- **To match every row, omit `where` — never pass a constant.** `where: c.bool(true)` is
|
|
440
|
+
not "no filter": a bare scalar literal states no condition and the engine reads the
|
|
441
|
+
resulting `context.search` as garbage, so it throws. An absent `where` is how "every
|
|
442
|
+
row" is spelled. A raw `Value` `where` stays the escape hatch for a clause built
|
|
443
|
+
elsewhere — `inp("clause")`, `ref("built_where")`, or `c.text("id > 0")`.
|
|
444
|
+
- **An `auth()`-keyed limiter collapses on any host with no caller identity — at every**
|
|
445
|
+
**tier.** `s.redis.ratelimit({ key: withFilters(c.text("rl:"), fl.concat(auth("id"))) })`
|
|
446
|
+
is the per-user form, but `auth()` is `null` on a public query, a task, or a function,
|
|
447
|
+
and every caller then shares one bucket. Attaching it once at
|
|
448
|
+
`apiGroup({ middleware })` or the workspace tier is the tempting DRY move and inherits
|
|
449
|
+
the same collapse onto every member endpoint. Export warns, naming the tier.
|
|
450
|
+
|
|
451
|
+
## Statements
|
|
452
|
+
|
|
453
|
+
Reachable through the `s` namespace: `s.<path>({...})`. Declarative statements
|
|
454
|
+
take one typed args object (field names match the engine); every surface's field
|
|
455
|
+
signature is in `llms/statements-catalog.md`. Specials (`[special]`) are
|
|
456
|
+
hand-authored: control flow is below, and the rest are in the `llms/statements-*`
|
|
457
|
+
topic files this doc's navigation names.
|
|
458
|
+
Wrap an `input`-routed `value` field in `ignored(...)` to store it but SKIP it at
|
|
459
|
+
runtime — the engine records `<name>:ignore` and the parameter falls back to its
|
|
460
|
+
default. Not the same as an empty value, and not the same as omitting the field
|
|
461
|
+
(which stores no entry at all). Mostly seen on a pulled workspace. A field written
|
|
462
|
+
into `context` instead (the `s.math.*`/`s.text.append` mutation family) has no slot
|
|
463
|
+
for the flag and THROWS rather than dropping it.
|
|
464
|
+
Fields marked `value` take a `Value` (`c.*`/`ref`/`inp`); `comparison` takes an
|
|
465
|
+
`expr(...)`. A `→ as: <type>` suffix names what the statement's `as:` output var
|
|
466
|
+
holds (curated, not exhaustive — absence means read the `[output]` flag and prose).
|
|
467
|
+
|
|
468
|
+
### Specials — authored signatures
|
|
469
|
+
|
|
470
|
+
Control flow & blocks (each nests a sub-stack; block specials name it `body`):
|
|
471
|
+
|
|
472
|
+
- `s.set_var(name, value)` · `s.update_var(name, value)` · `s.return(value)` · `s.comment(text)` — positional.
|
|
473
|
+
- **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 })`).
|
|
474
|
+
- **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`.
|
|
475
|
+
- `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[]`.
|
|
476
|
+
- `s.for({ as, count, body })` — **count-bounded** loop (`as` is the index), NOT from/to.
|
|
477
|
+
- `s.foreach({ as, list, body })` — iterate `list`; `as` is the current item.
|
|
478
|
+
- `s.while({ when, body })` — `when` is a condition (`expr`/`cmp`/`and`/`or`).
|
|
479
|
+
- `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.
|
|
480
|
+
- `s.try_catch({ try, catch?, finally? })` — three `Statement[]` blocks.
|
|
481
|
+
- `s.group(body)` / `s.util.post_process(body)` — take a `Statement[]` **positionally**.
|
|
482
|
+
- `s.foreach_break()` / `s.foreach_continue()` / `s.foreach_remove()` — nullary loop control.
|
|
483
|
+
- `s.expect.to_throw({ body, exception? })` — `body` is the statements expected to raise.
|
|
484
|
+
|
|
485
|
+
# Object kinds
|
|
486
|
+
|
|
487
|
+
> Read when you need the full catalog of authorable primitives — which factory builds each, which register method takes it, and which payload key it lands under. Read it too when a `register*` call will not typecheck because the array was built by `.flatMap()`/`.concat()` across modules.
|
|
488
|
+
|
|
489
|
+
Author with the factory, register on the Xano instance, lands under the payload key. Each line ends with a one-liner on what the primitive is.
|
|
490
|
+
|
|
491
|
+
⚠ Composing a workspace from independently-authored modules: `register*` takes its defs however they arrive, but `modules.flatMap((m) => m.tables)` does NOT typecheck. `Array.prototype.flatMap` binds its element type to the FIRST element, so every later module's tables are checked against that one table's schema — the error compares two unrelated column names and mentions neither `flatMap` nor the cause. `.concat()` collapses the same way. Two spellings work: pass an array LITERAL (`registerTables([...a.tables, ...b.tables])` — spreads are fine, TypeScript infers the union across every element at once), or annotate the module array with the wide aliases `AnyTableDef` / `AnyQueryDef` / `AnyFunctionDef` / `AnyAddonDef`, exported for exactly the four def types whose generics can collapse this way. Widening the array costs nothing: the typing you need lives on the `table()`/`query()` handle you hold and pass to `s.db.*`, and is never read back off the registered array.
|
|
492
|
+
|
|
493
|
+
- function: `defineFunction` → `Xano.registerFunctions` → payload `function` — Reusable server-side logic (a custom function) callable from any stack via `s.function.run`.
|
|
494
|
+
- table: `table` → `Xano.registerTables` → payload `dbo` — A database table: typed columns (`f.*`), indexes, and views; the schema other kinds read and write.
|
|
495
|
+
- query: `query` → `Xano.registerQueries` → payload `query` — An HTTP API endpoint (verb + path) bound to an API group; the main request/response surface.
|
|
496
|
+
- api_group: `apiGroup` → `Xano.registerApiGroups` → payload `app` — A container that groups queries under a shared base path, CORS, and swagger config.
|
|
497
|
+
- trigger (database): `tableTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when rows change on a bound table (insert/update/delete/truncate). The changed row is exposed as `t.new`/`t.old`, typed to the table when a `table()` handle is bound. Config-only (no response). `search` filters rows in the DATABASE, so it uses `col("NEW.x")`/`col("OLD.x")`, not `t`; invalid with `truncate`, and insert/delete cannot read the absent side.
|
|
498
|
+
- trigger (realtime_server): `realtimeServerTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when a client connects to or disconnects from a realtime server; inspect the connecting client and its permissions via `t`. Bind with `realtimeServer`. Response-bearing.
|
|
499
|
+
- trigger (channel): `realtimeChannelTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when a client joins or leaves a channel; inspect the addressed channel path and the client via `t`. Bind with a `realtimeChannel()` handle (a bare path is ambiguous across servers). Response-bearing.
|
|
500
|
+
- trigger (toolset): `mcpServerTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when an MCP client connects to a bound MCP server; gate or annotate the exposed tools via `t.toolset`/`t.tools`. Response-bearing.
|
|
501
|
+
- trigger (toolset): `agentTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when a client connects to a bound agent; gate or annotate its toolset via `t.toolset`/`t.tools`. Response-bearing.
|
|
502
|
+
- trigger (workspace): `workspaceTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires on branch lifecycle events (branch new/merge/live); inspect the from/to branch and action via `t`. Config-only.
|
|
503
|
+
- trigger (error): `errorTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when an error signature is first seen, regresses, or is marked fixed; inspect the error, caller, statement, and occurrence counts via `t`. Config-only.
|
|
504
|
+
- tool: `tool` → `Xano.registerTools` → payload `tool` — An agent/MCP tool: a callable capability with typed inputs an AI agent can invoke.
|
|
505
|
+
- mcp_server: `mcpServer` → `Xano.registerMcpServers` → payload `toolset` — An MCP server exposing a set of tools to external MCP clients.
|
|
506
|
+
- agent: `agent` → `Xano.registerAgents` → payload `toolset` — An AI agent: an LLM configuration plus the tools it can call. Invoke it from any stack (query/function/task/tool/trigger) with `s.ai.agent.run` — no public endpoint; the result is a rich envelope whose completion text is at `.result`.
|
|
507
|
+
- task: `task` → `Xano.registerTasks` → payload `task` — A scheduled background job (cron/interval) that runs a stack on a timer.
|
|
508
|
+
- workflow_test: `workflowTest` → `Xano.registerWorkflowTests` → payload `workflow_test` — An end-to-end test: a named stack with NO input and NO response that invokes other objects (`s.function.call`, `s.task.call`, `s.api.call`) and asserts on what they bind with `s.expect.*`. `datasource` defaults to `""` (an EMPTY datasource, recommended); naming one makes the engine CLONE that datasource before every run, so pointing a test at production-sized data can be slow enough to fail the run — `"live"` warns at compile time.
|
|
509
|
+
- middleware: `middleware` → `Xano.registerMiddleware` → payload `middleware` — A reusable pre/post stack attached to a query/function/task/tool/API group to run before or after its own logic.
|
|
510
|
+
- addon: `addon` → `Xano.registerAddons` → payload `addon` — A reusable read fragment that enriches a query result by joining related table data.
|
|
511
|
+
- realtime_server: `realtimeServer` → `Xano.registerRealtimeServers` → payload `realtime_server` — A realtime (websocket) server: the canonical-addressed container that owns realtime channels. Off until `enabled: true`. Returns a handle with `getUrl(baseUrl)`/`getPath()` for the client's socket URL (`wss://<host>/ws/<canonical>`).
|
|
512
|
+
- channel: `realtimeChannel` → `Xano.registerRealtimeChannels` → payload `channel` — A realtime channel: a joinable path on a realtime server (`rooms/{room_id}`) with typed path params, join/publish policy, a client-visible conversation transcript, and delivery semantics. Owns message handlers. Returns a handle with `getChannel(params)` for the path a client joins.
|
|
513
|
+
- message: `realtimeMessage` → `Xano.registerRealtimeMessages` → payload `message` — A realtime message handler: a named message type on a channel with its own typed payload and stack — the realtime analogue of a query. Pass the `realtimeChannel()` handle as `channel` and the owning server comes with it.
|
|
514
|
+
- microservice: `microservice` → `Xano.registerMicroservices` → payload `microservice` — A container workload deployed alongside the workspace, called from a stack with `s.microservice.request`. Two mutually exclusive shapes via `kind`: `builtin` declares containers (image/ports/resources/env/command/args) plus optional `ingresses`, and `helm` points at a chart and its `values` — passing both throws. EARLY SURFACE, expected to change — every export of a workspace declaring one prints a notice saying so. `configs`/`volumes` are typed and `@deprecated` but NOT deployable: the engine rejects an import carrying either, so `export()` fails the build rather than letting the deploy fatal. Put a value the workload reads in a container's `env`, and storage in a container's own `volumes` (`emptyDir`/`persistent`/`config`). Container names are free-form — they need not match the microservice name, which is what a stack addresses. SECRETS RIDE ALONG — `chart.values` and `registryAuth.dockerconfigjson` are carried into the bundle, and into a pulled tree, verbatim (they must be, or a pulled microservice could not be redeployed). Both are stored strings with NO deploy-time indirection: `process.env.X` in the def resolves at EXPORT and writes the literal into the bundle, so it is not a way to keep the credential out. Either leave `registryAuth` unset (public image, or a credential attached outside this workspace) or treat the bundle and any pulled tree as secret material — keep them out of git, or rotate after. Export prints a notice per microservice carrying either field; `--strict` does not promote it. For a secret a STACK reads, the mapped surface is `workspaceConfig({ env })` + `env("NAME")`.
|
|
515
|
+
- workspace: `workspaceConfig` → `Xano.registerWorkspace` → payload `workspace` — Workspace-level configuration such as default middleware chains and request-history defaults per host kind.
|
|
516
|
+
|
|
517
|
+
# Core def shapes
|
|
518
|
+
|
|
519
|
+
> Read when authoring a function, query, api group, task, workflow test, middleware, or tool — and for the `response` and `expr` shapes every one of them uses.
|
|
520
|
+
|
|
521
|
+
The def-object passed to each factory. `?` = optional. `input` is keyed by
|
|
522
|
+
input name (`input.<type>(opts?)`); `stack` is `Statement[]` (`s.*`); `response`
|
|
523
|
+
is a `ResponseDef` (see **Responses** below). Object identity is `guid?` —
|
|
524
|
+
omit it and it derives from `name` (set it to survive a rename).
|
|
525
|
+
|
|
526
|
+
- `defineFunction({ name, guid?, description?, docs?, workspace?, input?, stack?, response?, tests? })`
|
|
527
|
+
- `query({ name, verb, apiGroup?, guid?, auth?, input?, stack?, response?, responseType?, apiEnabled?, disabled?, cache?, description?, docs?, tests?, example? })`
|
|
528
|
+
- `verb`: `"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"` (required), UPPERCASE. Anything else — most often a lowercase `"post"` — makes `query()` THROW, because Xano does NOT reject it: it stores the verb as NULL, a null verb serves as GET, and the endpoint then answers on the wrong method while the one you meant 404s `Unable to locate request.`
|
|
529
|
+
- `apiGroup`: an `apiGroup()` def handle (or its name) — binds by guid, stable across syncs. Raw numeric `apiGroupId?` is the escape hatch and wins if both given.
|
|
530
|
+
- `auth`: `false` (no auth) or an auth-table id; `responseType`: `"standard" | "stream"` (default `standard`) — any other spelling THROWS, since Xano stores an unrecognized one as NULL and a null buffers as `standard`, so a misspelled stream quietly does not stream.
|
|
531
|
+
- `name` is the endpoint PATH within the group. A `{param}` segment is a URL PATH PARAM bound to the input of the same name, and segments chain: `name: "blog/{slug}/review/{review_id}"` + `input: { slug: input.text(), review_id: input.int() }`. Read it with `inp("slug")` like any other input. Every `{param}` MUST have a matching input or `query()` THROWS — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. A `{param}` need NOT be a whole segment (`"blog/post-{slug}"` routes fine), but its type must fit one segment (no object/list/json/file/geo/vector); there are no wildcards or patterns. `required: true` is NOT demanded (the engine's editor leaves path inputs unmarked). Inputs absent from the path are ordinary query-string/body params. Name charset is ONLY `A-Za-z0-9_-/{}`, max 200: a `.` (`"export.zip"`) is NOT rejected by Xano — it stores an EMPTY name that deploys clean then 404s forever, so `query()` THROWS. Use `"export_zip"` and set the extension in the response headers.
|
|
532
|
+
- **Client recipe:** `q.getPath({ params: { slug: "hello" } })` → `/api:<canonical>/blog/hello` — never interpolate by hand. `getPath` percent-encodes each value (so `?`/`#`/spaces stay in their segment) and throws on what encoding cannot contain: a `/`, and a value that IS `.`/`..` (a URL parser drops those before routing — `%2e` counts — addressing a different endpoint). The keys are typed from the literal `name`, so a typo is a compile error. The HANDLE's `q.toSearchParams(input)` drops path params for a GET; the free `query.toSearchParams(input)` has no view of the route and keeps every key.
|
|
533
|
+
- `apiGroup({ name, guid?, canonical?, description?, docs?, swagger?, apiGroupEnabled?, documentation?, cors? })` — a query container; register it and bind queries to it via their `apiGroup`.
|
|
534
|
+
- `cors?`: `{ mode?, allowOrigins?: string[], allowHeaders?: string[], allowCredentials?, maxAge?, allowMethods?: { get?, post?, put?, patch?, delete?, head? } }`.
|
|
535
|
+
- `mode?`: `"default"` (the default) | `"custom"` | `"disabled"`, lowercase — a fourth value THROWS at export (`apiGroup()` itself does not check), because Xano neither rejects nor blanks it: it DROPS THE WHOLE API GROUP on import, so the deploy succeeds and every query in the group 404s. ⚠ Every OTHER field applies only under `"custom"`: `"default"` serves a FIXED permissive policy (any origin, `allow-headers: *`, `allow-credentials: true`, `max-age: 86400`) and ignores the block, so setting `maxAge`/`allowCredentials`/`allowHeaders` alone changes nothing. `"disabled"` sends no CORS headers at all, so every browser call fails.
|
|
536
|
+
- ⚠ 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.
|
|
537
|
+
- `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.
|
|
538
|
+
- `task({ name, guid?, description?, docs?, datasource?, active?, tags?, history?, schedule?, stack?, middleware? })` — a scheduled background job (function-like `stack`, no `input`/`response`).
|
|
539
|
+
- `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).
|
|
540
|
+
- `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> })`.
|
|
541
|
+
- `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.
|
|
542
|
+
- `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.
|
|
543
|
+
- `resultStrategy?`: `"merge" | "replace"` (default `merge`) — how the middleware `response` folds into the host's.
|
|
544
|
+
- `exceptionPolicy?`: `"silent" | "rethrow" | "critical"` (default `"rethrow"` — a throw ABORTS the request and surfaces the authored error/status, which is what a guard wants). `"silent"` swallows the throw and lets the request through, so a guard set to it is NOT enforced — use it only for advisory middleware. `"critical"` is `"rethrow"` plus skipping the `post` chain.
|
|
545
|
+
- `tool({ name, guid?, description?, instructions?, docs?, enabled?, tags?, history?, input?, stack?, response?, responseShape?, middleware? })` — a function-like operation (`input`/`stack`/`response`) that a toolset (MCP server or agent) exposes. Register it, then reference it from a toolset's `tools`.
|
|
546
|
+
### Responses
|
|
547
|
+
|
|
548
|
+
The `response?` field (on functions, queries, tools, middleware, and
|
|
549
|
+
response-bearing triggers) maps to the stored `result[]`:
|
|
550
|
+
|
|
551
|
+
- `ResponseDef = Value | Record<string, Value>`.
|
|
552
|
+
- A single `Value` → one unnamed result item: `response: ref("rows")`.
|
|
553
|
+
- A record → one named item per key: `response: { user: ref("u"), token: ref("t") }`.
|
|
554
|
+
- Omitted → empty `result[]` (no response body).
|
|
555
|
+
|
|
556
|
+
### Expressions (`expr`)
|
|
557
|
+
|
|
558
|
+
`expr(left, op, right)` builds the comparison used by every condition/`where`
|
|
559
|
+
surface — `s.conditional`/`s.while` `when` (incl. each `elif` branch), and
|
|
560
|
+
`db.query` `where`/`additionalWhere` (and the search triggers) — one shared tree.
|
|
561
|
+
|
|
562
|
+
- `op`: `=`, `!=`, `>`, `<`, `>=`, `<=` (JS aliases `==` `===` `!==` are accepted and normalized).
|
|
563
|
+
- `left`/`right` are `Value`s — `col("x")` (a table column), `ref`, `inp`, `auth(...)`, or `c.*`.
|
|
564
|
+
- For the full operator set (`in`/`like`/`ilike`/`between`/`contains`/`overlaps`/`@>`/`~`/`search`/…)
|
|
565
|
+
use `cmp(left, op, right, { ignoreEmpty? })`; compose nested boolean logic with `and(...)`/`or(...)`.
|
|
566
|
+
- ⚠ The wider `cmp` operators are DATABASE-only (`where`, table view filter, db trigger
|
|
567
|
+
`search`). A RUNTIME condition — `s.conditional`/`elif`, `s.while`, `s.precondition`,
|
|
568
|
+
`array.*` `if` — takes the `expr` set only; the rest are refused at build time because
|
|
569
|
+
deployed they fail the request with `Invalid op: <op>` on that branch, usually a guard.
|
|
570
|
+
Spell membership out: `or(expr(x, "=", a), expr(x, "=", b))`.
|
|
571
|
+
- A condition/`where` accepts a single `expr(...)`/`cmp(...)`, an `and()`/`or()` group, an array of
|
|
572
|
+
those (ANDed), or (for `where`) a raw `Value`. `s.conditional`/`s.while`/`s.switch`, `db.query`,
|
|
573
|
+
`precondition`, and the `array.*` predicates all take the same TREE shape (operators per above).
|
|
574
|
+
- ⚠ `mixed(a, { or: b }, { and: c })` reproduces a container whose terms do NOT all join the
|
|
575
|
+
same way — the editor allows it, so pulled workspaces contain it. **Do not author it.** The
|
|
576
|
+
stored form does not record the grouping, and the two places it can appear disagree: a
|
|
577
|
+
branch (`s.conditional`/`s.while`/`precondition`) folds terms strictly left to right, so
|
|
578
|
+
`a OR b AND c` is `(a OR b) AND c`, while a `db.query` filter applies the engine's
|
|
579
|
+
AND-before-OR precedence and selects `a OR (b AND c)`. Write `and(or(a, b), c)` or
|
|
580
|
+
`or(a, and(b, c))` — each says one reading in every context. Pulls report these as
|
|
581
|
+
`ambiguous-condition`.
|
|
582
|
+
- A **filtered** operand (`withFilters(...)`) works inline in any condition/`where` (conditional,
|
|
583
|
+
while, `db.query`/addon, …) — e.g. `cmp(withFilters(col("title"), fl.trim()), "=", inp("q"))`.
|
|
584
|
+
- e.g. `db.query({ table: posts, where: expr(col("author"), "=", auth("id")), as: "rows" })`.
|
|
585
|
+
|
|
586
|
+
# Saved unit tests, assertions, and mocks
|
|
587
|
+
|
|
588
|
+
> Read when authoring a `workflowTest()` stack, when a query/function/middleware carries `tests`, when a statement should mock a value, or when running a deployed environment's tests.
|
|
589
|
+
|
|
590
|
+
Named input sets run against one object, with assertions on its response — the tests the Xano editor shows. NOT `workflowTest()`, which is a standalone object with its own stack that calls other objects.
|
|
591
|
+
|
|
592
|
+
- `tests?: TestDef[]` on `query()`, `defineFunction()`, and `middleware()`. No other kind stores them.
|
|
593
|
+
- `{ name, id?, description?, datasource?, input?, expect?, token? }`.
|
|
594
|
+
- `name` is unique within the object and is the key a statement's `mock` resolves against; a duplicate throws.
|
|
595
|
+
- `id` defaults to a derivation from the owning object plus the name. A pulled test carries the id Xano minted, emitted explicitly.
|
|
596
|
+
- `datasource` defaults to `""` — an EMPTY datasource. Any other value names one the engine CLONES before the run; `"live"` warns at encode time.
|
|
597
|
+
- ⚠ That empty default means **no `table({ seed })` rows exist while a unit test runs**, exactly as for a `workflowTest()` — every `db` read misses and a count or index assertion (`resp("0.id")`) fails against a deployment whose endpoint returns those rows over HTTP a second later. Create what the test needs INSIDE the run — a `defineFunction` fixture the stack calls first — or `mock` the read.
|
|
598
|
+
- `input` is `{ <input name>: Value }` — tagged values (`c.*`, `ref`, …), never plain JS scalars.
|
|
599
|
+
- `token` runs the test as an authenticated caller. A pull does NOT bring it back: it is an expiring credential, reported as a deliberate omission.
|
|
600
|
+
|
|
601
|
+
### Assertions: `expect.*`, not `s.expect.*`
|
|
602
|
+
|
|
603
|
+
- `expect.*` builds a `{type, vars}` record stored on a `tests` entry. `s.expect.*` builds a STATEMENT for a `workflowTest()` stack. Different return types; not interchangeable.
|
|
604
|
+
- Subject first — argument order is the assertion: `expect.to_equal(resp(), c.int(2))`.
|
|
605
|
+
- `resp()` is the response under test; `resp("id")` drills, `resp("a-b")` takes the bracket escape.
|
|
606
|
+
- Subject only: `to_be_defined`, `to_not_be_defined`, `to_be_empty`, `to_be_null`, `to_not_be_null`, `to_be_true`, `to_be_false`, `to_be_in_the_future`, `to_be_in_the_past`.
|
|
607
|
+
- Subject + operand: `to_equal`, `to_not_equal`, `to_be_greater_than`, `to_be_less_than`, `to_contain`, `to_start_with`, `to_end_with`, `to_match` (operand is a delimiter-wrapped pattern — build it with `c.regex`).
|
|
608
|
+
- `expect.to_be_within(subject, min, max)` — both bounds EXCLUSIVE; a subject equal to either fails.
|
|
609
|
+
- `expect.to_throw(exception?)` takes NO subject. With an argument the error message must CONTAIN that text (case-insensitive substring); with none, any error passes.
|
|
610
|
+
|
|
611
|
+
### Mocks
|
|
612
|
+
|
|
613
|
+
A statement returns a value instead of doing its work, for one named test.
|
|
614
|
+
|
|
615
|
+
- `s.set_var("x", c.int(1), { mock: { "adds one": c.int(123) } })`. Every statement takes `mock`.
|
|
616
|
+
- Keyed by TEST NAME. A name the object does not declare THROWS at encode: the engine ignores a mock whose key is not a real test id, so it would deploy clean and silently never apply.
|
|
617
|
+
- `{ value, enabled: false }` keeps a mock stored but switched off.
|
|
618
|
+
- A mock applies ONLY while its test runs. It changes nothing about a normal request.
|
|
619
|
+
|
|
620
|
+
### `example`
|
|
621
|
+
|
|
622
|
+
`example?: { input?, output? }` on `query()` — the saved request/response sample the editor records. Free-form JSON, not tagged values. A pull DOES bring it back.
|
|
623
|
+
|
|
624
|
+
### What a `workflowTest()` run actually sees
|
|
625
|
+
|
|
626
|
+
The run is isolated in ways that make a correct test fail for reasons the failure message does not name.
|
|
627
|
+
|
|
628
|
+
- 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.
|
|
629
|
+
- `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.
|
|
630
|
+
- `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.
|
|
631
|
+
- `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.
|
|
632
|
+
- 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.
|
|
633
|
+
- 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.
|
|
634
|
+
|
|
635
|
+
### Running them
|
|
636
|
+
|
|
637
|
+
`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.
|
|
638
|
+
|
|
639
|
+
- `--dest ephemeral` (DEFAULT, `--name <env>` to pick one), `--dest sandbox`, or `--dest workspace`. Unlike `deploy`, `workspace` is allowed here — running a test reads.
|
|
640
|
+
- `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.
|
|
641
|
+
- `--kind unit|workflow` narrows to one family. `--concurrency <n>` defaults to 1: tests share the environment database.
|
|
642
|
+
- 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.
|
|
643
|
+
- `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.
|
|
644
|
+
|
|
645
|
+
# Agent and MCP def shapes
|
|
646
|
+
|
|
647
|
+
> Read when the workspace defines an `agent()` or an `mcpServer()`.
|
|
648
|
+
|
|
649
|
+
- `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`, so a server that carries LLM settings can say so. 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 (an earlier result) THROWS, so resolve ONCE from the instance base URL. Fires on an ephemeral; does NOT fire in the sandbox (see Gotchas).
|
|
650
|
+
- `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.
|
|
651
|
+
- `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 })`.
|
|
652
|
+
- `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.
|
|
653
|
+
- `tools?`: same `ToolsetToolEntry[]` as `mcpServer` — bare `tool()` handles, or a `{ tool, enabled?, auth? }` wrapper when one needs `enabled`/`auth`.
|
|
654
|
+
- `output?`: `{ schema: Record<string, input.*>, enabled? }` — structured-output schema. `schema` is a named-field record authored with the `input.*` catalog, exactly like a `defineFunction`/`query` `input:` map (the stored `structuredOutputsSchema` is the same wire shape as function inputs). e.g. `output: { schema: { priority: input.enum(["low","high"]), summary: input.text() } }`. When you pass the agent *handle* to `s.ai.agent.run({ agent })`, `.result` is typed straight from this schema — no `resultShape` witness needed (the shape is declared once). `resultShape` remains only to override that, or to type an agent referenced by bare name.
|
|
655
|
+
- **Run + read recipe (`s.ai.agent.run`):** bind the run to a var (`s.ai.agent.run({ agent, args, as: "run" })`) — it produces a rich envelope, and the completion is at **`.result`**. Read one structured field with a dotted ref (`response: ref("run.result.priority")`) or the whole completion (`ref("run.result")`, typed from `output.schema`); persist it in a later step the same way (`s.set_var({ name, value: ref("run.result.summary") })`). `args` is a plain object of run inputs (`{ topic: inp("topic") }` — raw literals are fine, e.g. `{ max_steps: 3 }`) surfaced to the agent as `{{ $args.topic }}`. Tool-call / per-step data (when tools ran) rides `.toolCalls` / `.steps` on the same envelope — both optional, absent or empty when no tools executed.
|
|
656
|
+
|
|
657
|
+
# Realtime def shapes
|
|
658
|
+
|
|
659
|
+
> Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.
|
|
660
|
+
|
|
661
|
+
- **Realtime** — the only three-level chain: `realtimeServer` owns `realtimeChannel`s, which own `realtimeMessage` handlers. Pass the HANDLE, not a name (a channel path is unique only within its server).
|
|
662
|
+
- `realtimeServer({ name, guid?, description?, enabled?, canonical?, tags?, history? })` — the container.
|
|
663
|
+
- `enabled` defaults to **false** — the one `enabled` in the SDK that does.
|
|
664
|
+
- An enabled server with no active channel still refuses the handshake.
|
|
665
|
+
- `realtimeChannel({ name, server, guid?, description?, active?, input?, anonymousClients?, presence?, publish?, conversation?, delivery?, rateLimit?, tags?, history? })`
|
|
666
|
+
- `name` is a PATH (`"lobby"`, `"rooms/{room_id}"`); `input` types its `{param}` segments, NOT the payload. Every `{param}` MUST have a matching input, and that input must be a SCALAR and not a list (`json`/`object`/`array: true` have no URL form), or `realtimeChannel()` THROWS. `required: true` is NOT checked and is not needed — segment counts must match, so the segment is always present at join. Name charset as query (`A-Za-z0-9_-/{}`, max 200), and so is `tool`; `realtimeMessage` is NARROWER — no `/` or `{}`.
|
|
667
|
+
- Matching is STRICT: a literal segment beats a param (`rooms/lobby` and `rooms/{room_id}` coexist); segment counts must be EQUAL (`rooms/{room_id}` does NOT match `rooms/42/edit`); literals are CASE-SENSITIVE; an empty segment is REJECTED, not collapsed (a leading/trailing/doubled `/` matches nothing). `getChannel()` throws on an empty or slash-bearing param for that reason.
|
|
668
|
+
- An INACTIVE channel reports the same error as a nonexistent one — deactivating leaks nothing.
|
|
669
|
+
- `anonymousClients` is gated TWICE: the server admits the connection, then the channel admits the join. Setting it here alone is not enough.
|
|
670
|
+
- `publish?: { who?: "nobody"|"anyone"|"authenticated", direct? }` — `who` defaults to `nobody`: nobody can publish until you set it. `direct` (default false) lets a client address ANOTHER CLIENT via a frame's `options.socketId`, and is checked BEFORE `who`.
|
|
671
|
+
- `conversation?: { enabled?, limit?, ttl? }` — the client-visible TRANSCRIPT replayed to a joiner (distinct from `history`, which is execution history). ⚠ `limit` DEFAULTS TO 0 AND 0 MEANS OFF: `{ enabled: true }` alone records nothing and replays nothing, silently. `ttl` is an IDLE expiry of the WHOLE transcript, refreshed by every write (an active channel never ages out; a silent one loses all of it at once) — NOT a per-message age cap.
|
|
672
|
+
- `delivery?: { guarantee?: "at_most_once"|"at_least_once", perRecipient? }` — `perRecipient` is independent of the guarantee, is a NO-OP unless the channel declares a `deliver` trigger, and costs a stack PER RECIPIENT PER MESSAGE. Per-viewer redaction needs BOTH HALVES — this flag AND an active `deliver` trigger bound to the channel — and with either missing the payload is delivered UNCHANGED to everyone; `export()` warns on each half alone.
|
|
673
|
+
- `rateLimit?: { messagesPerMinute? }` — 0 = unlimited, checked BEFORE the handler runs. A COST guardrail, not a security control: an anonymous client is bucketed per CONNECTION (reconnecting resets it), and it fails OPEN when its store is down.
|
|
674
|
+
- `realtimeMessage({ name, channel, server?, guid?, description?, active?, auth?, deliverTo?, input?, middleware?, stack?, response?, responseShape?, history?, disabled?, tags? })` — the invocable unit (the realtime analogue of a query).
|
|
675
|
+
- `input` types the message PAYLOAD. `server` is required only when `channel` is a bare path.
|
|
676
|
+
- `deliverTo?`: `"channel"` (default) | `"sender"` | `"others"` | `"explicit"`. ⚠ `"explicit"` still delivers to NOBODY — nothing selects recipients from inside a handler, and `s.realtime.publish` (which originates an event INTO a channel) is not a substitute.
|
|
677
|
+
- Only `"channel"`/`"others"` fan out AND are written to the `conversation` transcript — a `"sender"` response is invisible to every future joiner.
|
|
678
|
+
- **Both input surfaces read as ordinary inputs:** `inp("body")` for a payload field, `inp("room_id")` for the channel's `{room_id}`. No session lookup, no frame parsing.
|
|
679
|
+
- A path param is bound ONCE at join and read from the connection thereafter, never from the frame — a sender cannot claim a room it did not join. The same values reach a channel `join`/`leave` trigger's stack.
|
|
680
|
+
- `s.realtime.get_session({ as })` — the CALLER's realtime session for the current frame. FLAT shape:
|
|
681
|
+
- `authenticated` bool · `client_id` text (the AUTHED ROW ID as text, `""` anonymous) · `dbo_id` int (the auth TABLE's id — NOT the user's row id; `0` anonymous — to look the caller up use `client_id`. `dbo_id` is an int in the same position and typechecks, so a gate that keys on it finds no user and refuses EVERYONE) · `socket_id` int (transport id) · `channel` text (resolved path, `""` in a server trigger) · `params` object (bound path params, `{}` when none — `ref("session.params.room_id")`) · `extras` object · `opened_at` decimal.
|
|
682
|
+
- Works in a realtime MESSAGE stack and in CHANNEL and SERVER trigger stacks; off that path it degrades to an anonymous session.
|
|
683
|
+
- For a path param prefer `inp("room_id")`. Reach for the session when you need the CONNECTION (identity/extras) — "who is this sender" on an anonymous-client channel.
|
|
684
|
+
- ⚠ THREE UNRELATED THINGS ARE CALLED A CLIENT ID: `session.client_id` (app-facing identity), `session.socket_id` (transport), and a frame's `options.client_id` (the at_least_once CURSOR handle). Conflating the first and last breaks at_least_once for anonymous clients.
|
|
685
|
+
- `s.realtime.publish({ server, channel, data, message?, authTable?, authId? })` — the PUSH direction: originate a server-authored event onto a channel from ANY stack, no client frame first.
|
|
686
|
+
- `server` is the handle or its NAME (resolved by name, not guid); `channel` is the FILLED-IN path (`channel.getChannel({ room_id: 42 })`), never the template — a constant still carrying `{param}` THROWS at author time, and a constant `server`/`channel` naming nothing this workspace registers WARNS at export.
|
|
687
|
+
- A PER-ROW path whose id is only known at runtime is built as a value, not with `getChannel()` (which needs the id at author time): `withFilters(c.text("rooms/"), fl.concat(ref("room.id")))`, or `s.set_var` + `s.text.prepend`. A computed `channel`/`server` — a `ref`/`inp`, or a constant carrying a filter chain — is left alone by the export check.
|
|
688
|
+
- DELIVERY-ONLY — fanned out as-is; does NOT invoke a `realtimeMessage()` handler even when `message` names one (a channel `deliver` trigger still runs).
|
|
689
|
+
- SERVER-AUTHORITATIVE — bypasses `publish.who`, which governs CLIENTS. Authorize in your own stack.
|
|
690
|
+
- ⚠ FAIL-SOFT — a missing/disabled server or dead bus is swallowed engine-side, so a mis-targeted publish is SILENT with no result to check.
|
|
691
|
+
- `authTable`/`authId` are ASSERTED attribution on the frame — not a credential, nothing validates them.
|
|
692
|
+
- **Client recipe (derive, never hardcode):**
|
|
693
|
+
- `server.getUrl(baseUrl)` → `wss://<host>/ws/<canonical>` — accepts the `https://…` instance base URL and normalizes the scheme. `channel.getChannel({ room_id: 42 })` → `"rooms/42"`, the path that goes in a frame's `channel` field. Both throw rather than guess. A canonical is minted by `xanots export <entry> --lock`.
|
|
694
|
+
- Auth is a bearer token passed as the websocket SUBPROTOCOL: `new WebSocket(url, token)`. No token = an anonymous client, admitted only where `anonymousClients: true`.
|
|
695
|
+
- Frames are JSON `{ action: "join"|"leave"|"broadcast"|"ack"|"ping"|"presence", channel, type?: <message name>, payload?, options?, id? }`. You must `join` before you may `broadcast`, and the server's context is ready only a moment after `open` — an immediate first frame is refused.
|
|
696
|
+
- `options` is `{ socketId?, client_id?, channel? }` — `socketId` addresses another client directly (needs `publish.direct`), `client_id` is the at-least-once cursor handle, and `options.channel` WINS over a top-level `channel`.
|
|
697
|
+
- ⚠ KEEP THE SOCKET ALIVE: an idle connection is REAPED after ~10 minutes. A LISTEN-ONLY client (a feed or dashboard that joins and rarely publishes) MUST send `{ action: "ping" }` (answered `pong`) or any frame periodically or it silently drops.
|
|
698
|
+
- Server frames: `join` (ack `{ joined: true, params }`, + `cursor`/`resumed` on at_least_once) · `message` · `replay` · `broadcast` · `presence_full`|`presence_join`|`presence_leave` · `conversation_start`|`conversation_end` (replayed frames flagged `conversation: true`) · `pong` · `ack` · `error`.
|
|
699
|
+
- ⚠ `broadcast` is a RECEIPT to the sender, not a delivery confirmation: `payload.delivered_local` counts recipients on the ANSWERING NODE ONLY, not the channel. It also carries `id` on at_least_once and `dropped: true` when the handler returned null.
|
|
700
|
+
- `error` carries `payload.message`, plus `code`/`limit`/`retry_after` when rate limited. `rate_limited` is the ONLY code — do NOT switch on `code`.
|
|
701
|
+
- An `error` is a per-frame refusal, NOT a disconnect — EXCEPT a failed handshake and a REFUSED `connect` trigger, which each send one and then CLOSE with code 4401.
|
|
702
|
+
- **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.
|
|
703
|
+
- Socket: `server.getUrl(base, { tenant })` → `/ws/<tenant>:<canonical>`. ⚠ A bare canonical on a tenant host resolves against the INSTANCE workspace instead.
|
|
704
|
+
- 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.
|
|
705
|
+
- 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.
|
|
706
|
+
- ⚠ `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.
|
|
707
|
+
- 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.
|
|
708
|
+
- ⚠ 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.
|
|
709
|
+
- **Presence frames** (a `presence: true` channel only):
|
|
710
|
+
- `presence_full` carries `payload.members` — an ARRAY holding the WHOLE roster, including the receiving client. `presence_join`/`presence_leave` carry a single `payload.member`.
|
|
711
|
+
- A member is `{ id, dbo_id, authenticated, extras, joined_at }`: `id` the auth row id as a string (`""` anonymous), `dbo_id` the auth table's id (`0` anonymous), `extras` the connection's extras object, `joined_at` epoch SECONDS.
|
|
712
|
+
- Render from `presence_full`, then apply the deltas. The roster counts MEMBERS, not connections (refcounted per identity — a second tab fires no second `presence_join`).
|
|
713
|
+
- Join order: `join` ack → `presence_full` → (others get `presence_join`) → conversation replay → `replay` frames.
|
|
714
|
+
- A joined client can re-request the snapshot any time with `{ action: "presence", channel }`, answered to the SENDER only. A socket that never joined is REFUSED — the roster is not readable without membership.
|
|
715
|
+
- **Conversation frames — the transcript hydrates the client, so DO NOT build a hydration endpoint.**
|
|
716
|
+
- On a `conversation` channel the replay is PUSHED automatically at join, unasked: `conversation_start` (`payload.count`) → the last `limit` messages, each a normal `action: "message"` frame carrying its ORIGINAL `type` and `payload` plus `conversation: true` and the original `ts` → `conversation_end`.
|
|
717
|
+
- So the client needs NO fetch, no `GET /messages`, and no table read to paint the initial view. Render `message` frames identically either way; the backfill paints itself.
|
|
718
|
+
- ⚠ `{ enabled: true }` ALONE IS A NO-OP: `limit` defaults to 0, and 0 means RETAIN NONE (not retain everything), so the transcript is never written and never replayed, with no error. ALWAYS PASS `limit`.
|
|
719
|
+
- The POST-HANDLER broadcast payload IS the stored transcript row — a handler must broadcast everything the UI needs to render a past message (author name, id, `created_at`). Nothing else is replayed.
|
|
720
|
+
- Only `deliverTo` `"channel"`/`"others"` are RECORDED, so a `"sender"` response is invisible to every future joiner by construction.
|
|
721
|
+
- The transcript is a capped ring (`limit`, `ttl`), not storage. Persist to a table only for durability, search, or reads BEYOND that window — never merely to hydrate a joiner.
|
|
722
|
+
- **`delivery.guarantee: "at_least_once"` is a CLIENT CONTRACT, not just a channel setting.**
|
|
723
|
+
- The client must ACK what it receives — `{ action: "ack", channel, id }`, confirmed by `{ action: "ack", channel, payload: { cursor } }`.
|
|
724
|
+
- ⚠ An ANONYMOUS client must ALSO send a durable `options.client_id` in its JOIN frame (once; later acks need not repeat it). WITHOUT one it has no cursor, its acks are SILENTLY IGNORED, and it degrades to at_most_once. An AUTHENTICATED client is keyed by identity and needs no `client_id`.
|
|
725
|
+
- The missed gap arrives after join as `replay` frames, oldest-first, each with an `id` to ack.
|
|
726
|
+
- DISTINCT from the conversation transcript: `conversation_*` is the SHARED "what was said before I arrived", `replay` is the PER-CLIENT "what I missed while disconnected". Both may be on.
|
|
727
|
+
- How far back `replay` reaches is sized by `conversation.ttl` (here a REAL per-message age cut, and it BEATS `limit`), else `conversation.limit`, else 1000 — even on a channel with no transcript enabled.
|
|
728
|
+
- **What a message handler RETURNS decides delivery, and the failure directions are NOT symmetric.**
|
|
729
|
+
- A returned value fans out per `deliverTo` and becomes the transcript row.
|
|
730
|
+
- Returning NULL delivers NOTHING — the supported way to veto a message (the sender is told `dropped: true`).
|
|
731
|
+
- A payload REJECTED by the declared `input` also delivers nothing,; the detail goes ONLY to the sender.
|
|
732
|
+
- ⚠ But a handler that CRASHES FAILS OPEN: the sender's ORIGINAL, UNVALIDATED payload is broadcast to the channel unchanged. A handler doing redaction or authorization must NOT be the only thing between client input and subscribers.
|
|
733
|
+
|
|
734
|
+
# Triggers
|
|
735
|
+
|
|
736
|
+
> 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.
|
|
737
|
+
|
|
738
|
+
**A trigger's `stack` is a callback — `stack: (t) => [...]`, not the plain
|
|
739
|
+
`stack: []` array that `defineFunction`/`query`/`task` use.** That's the one
|
|
740
|
+
shape that doesn't carry over from the other kinds: a trigger has no
|
|
741
|
+
user-declared `input`, so its inputs are **implied by type** (fixed by Xano,
|
|
742
|
+
not editable) and arrive through the typed **stack handle** `t` — you can't
|
|
743
|
+
reference them without it. (Response-bearing types take `response: (t) =>
|
|
744
|
+
ResponseDef` too.) `t` exposes exactly that trigger type's inputs; a wrong
|
|
745
|
+
name is a compile error, not a runtime surprise. The seven trigger types are
|
|
746
|
+
distinct root factories (not a namespace): `{tableTrigger, realtimeServerTrigger,
|
|
747
|
+
realtimeChannelTrigger, mcpServerTrigger, agentTrigger, workspaceTrigger,
|
|
748
|
+
errorTrigger}({ name, guid?, description?, active?, tags?, ... })`.
|
|
749
|
+
|
|
750
|
+
- `tableTrigger({ name, table?, datasources?, actions?: {insert?,update?,delete?,truncate?}, stack })` — database/table trigger. `t.new` / `t.old` are the row **after** / **before** the change; `t.action` (`insert|update|delete|truncate`), `t.datasource`. Bind `table` to a `table()` handle and `t.new("col")` / `t.old("col")` are typed to that row (misspelled column = compile error). Nullability follows the enabled actions: insert → `old` is null, delete → `new` is null, update → both, truncate → neither. Config-only (no response).
|
|
751
|
+
- `realtimeServerTrigger({ name, realtimeServer, actions?: {connect?,disconnect?}, stack?, response?, responseShape? })` — realtime SERVER lifecycle (a client connecting to / disconnecting from the server, not a message). Inputs: `t.action` (`connect|disconnect`), `t.realtime_server`, `t.client`. Bind `realtimeServer` to a `realtimeServer()` handle (or its name). `connect` GATES the connection — a denial sends an `error` and CLOSES the socket with code 4401 before it is ever ready, so it is a real front door, not an observer; same return shape as a channel `join` (`{ allowed: c.bool(true) }` or any truthy value admits, EMPTY/FALSY DENIES — INCLUDING a gating trigger with NO `response`, which returns nothing and so refuses every client). A CRASH DENIES too — the transport seeds a deny and keeps it on a throw. Both failure modes lock the door, so plan for a self-inflicted LOCKOUT (an unguarded drill into a null `db.get` raises → everyone refused), not a breach. Gating is OPT-IN: a server with no `connect` trigger accepts every connection. `disconnect` is OBSERVATIONAL (return ignored, throws swallowed — cleanup must always complete). Both are SERVER-scoped, so `s.realtime.get_session` works but carries no channel path and no bound params.
|
|
752
|
+
- `realtimeChannelTrigger({ name, channel, actions?: {join?,leave?,deliver?}, stack?, response?, responseShape? })` — realtime CHANNEL lifecycle. Inputs: `t.action` (`join|leave|deliver`), `t.channel`, `t.client`. Bind `channel` to a `realtimeChannel()` handle — a bare path is NOT accepted (it is unique only within its server). The three actions have DIFFERENT postures, and the posture decides what the stack should return: `join` GATES the join (it runs BEFORE the client becomes a member, so a denial means it never sees a fan-out) — return `{ allowed: c.bool(true) }` (optional `reason` reaches the client) or any truthy value to admit, and an EMPTY OR FALSY RETURN DENIES, so a stack that just falls through — or a gating trigger with NO `response` — refuses everyone, and a CRASH DENIES too. That is the inverse of a normal message (a crashing message still delivers) and of `deliver` below (a gate that fails OPEN). `join`/`leave` bind the channel's typed path params as INPUTS, so `inp("room_id")` resolves and the gate decides per room; a SERVER connect/disconnect has no channel, the one place a path param cannot be read; `leave` is OBSERVATIONAL (return ignored, throws swallowed); `deliver` GATES delivery PER RECIPIENT — the per-viewer redaction tool and the most expensive action here (a stack per recipient per message), and it needs `delivery.perRecipient` on the channel to run at all — BOTH HALVES are required, so a `deliver` trigger on a channel without the flag NEVER RUNS and every subscriber receives the UNREDACTED payload (no error, no log line); `export()` warns on each half alone. **`deliver`'s RETURN VALUES DO NOT READ LIKE A FILTER:** ONLY an explicit NULL drops the message for that recipient; an OBJECT replaces that recipient's payload; ANYTHING ELSE — INCLUDING `false`, `0`, `""` — DELIVERS IT UNCHANGED, as does a crash. So `return false` from a yes/no redaction check SENDS the message it was written to suppress — return null instead. The delivered payload arrives NESTED, so read `inp("payload").<field>`, and `t.client` is the SENDER while `s.realtime.get_session` describes the RECIPIENT this run is for.
|
|
753
|
+
- `mcpServerTrigger({ name, mcpServer, stack?, response?, responseShape? })` / `agentTrigger({ name, agent, stack?, response?, responseShape? })` — toolset connection. Bind with the `mcpServer()`/`agent()` def handle (or its name) — it resolves to the toolset guid at export. Raw numeric `objId` is the escape hatch, rarely right: ids are assigned at import, so a handle passed to `objId` is a type error, and binding nothing deploys a trigger that never fires. Inputs: `t.toolset` (`t.toolset("name")`), `t.tools`. Response-bearing; the default stack copies `toolset`/`tools` into vars and returns them.
|
|
754
|
+
- `workspaceTrigger({ name, actions?: {branch_live?,branch_merge?,branch_new?}, stack? })` — branch lifecycle. Inputs: `t.to_branch`, `t.from_branch`, `t.action`. Config-only.
|
|
755
|
+
- `errorTrigger({ name, stack? })` — error-signature trigger. Inputs: `t.event` (`new|regression|fixed`), `t.id`, `t.signature`, `t.error` (`t.error("code")`/`t.error("message")`), `t.caller`, `t.statement`, `t.actor`, `t.count`, `t.first_seen`, `t.last_seen`, `t.fixed_at`. Config-only.
|
|
756
|
+
|
|
757
|
+
# Array and database statements
|
|
758
|
+
|
|
759
|
+
> Read when the stack reads or writes rows (`s.db.*`), or transforms an array in place (`s.array.map`, `s.array.union`).
|
|
760
|
+
|
|
761
|
+
Array blocks (an `if`/`transform` is applied per item):
|
|
762
|
+
|
|
763
|
+
- `s.array.map({ source, as?, transform? })` — `transform` is either a per-item `Value` expression (each item maps to that value) or a **record of values** (each item maps to an object with those keys), or a list of `{ key, value }` pairs, for a key computed per item or two rows sharing one. Use `ref("$this")` for the item and `ref("$index")` for its position. These are THIS statement's own bindings, in a value expression — not the JavaScript lambda contract (see **Lambda bodies**), which binds a different set per surface and is written with `lam.fn`.
|
|
764
|
+
- `s.array.union({ source, with?, as?, transform? })` — set-union two arrays.
|
|
765
|
+
|
|
766
|
+
DB reads/writes (`table` is a def handle or name; `fieldName` defaults to the
|
|
767
|
+
primary key `id`):
|
|
768
|
+
|
|
769
|
+
- `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).
|
|
770
|
+
- `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.
|
|
771
|
+
- `s.db.has({ table, fieldName?, fieldValue, as? })` — existence test.
|
|
772
|
+
- `s.db.del({ table, fieldName?, fieldValue, as? })` — delete by field match.
|
|
773
|
+
- `s.db.add({ table, row?, data?, output?, as? })` — insert; `row` is a partial keyed by column.
|
|
774
|
+
- A row CELL takes a tagged `Value`, a nested object of sub-keys, or a bare JS literal typed against that column: `row: { is_hidden: true, notes: "…" }` encodes exactly as `{ is_hidden: c.bool(true), notes: c.text("…") }`. The tag comes from the COLUMN, not the literal — `10` on an `f.decimal()` column is `const:decimal`, not `const:int` — so a literal contradicting its column is a compile error on a `f.*`-schema table (`{ is_hidden: "yes" }` on an `f.bool()` column) and throws at encode on a raw-`ColumnDef[]` one. An `f.enum()` column keeps its member union. A column with no literal form — obj/json/list/geo/vector/file — still needs `c.obj`/`c.array`.
|
|
775
|
+
- `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 (omitted takes the column's type default on `add`: `[]` for a list, `{}` for obj/json, else `null`; on `edit` it is left untouched). A column's `nullable` is not consulted; the engine refuses a null it forbids.
|
|
776
|
+
- 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.
|
|
777
|
+
- `s.db.edit({ table, fieldName?, fieldValue, row?, data?, output?, as? })` — update by field match.
|
|
778
|
+
- `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` — merge a partial (`data` is an object value).
|
|
779
|
+
On these three, `output` restricts the columns of the RETURNED row only — it does not change
|
|
780
|
+
what is written. Not offered on `db.del`/`db.has` (their result is a scalar) or on
|
|
781
|
+
`db.add_or_edit` (no output envelope).
|
|
782
|
+
- `s.db.add_or_edit({ table, fieldName?, fieldValue, row?, data?, as? })` — upsert.
|
|
783
|
+
- `s.db.query({ table, where?, additionalWhere?, bind?, sort?, paging?, external?, returnType?, distinct?, eval?, output?, lock?, addon?, as? })` — search.
|
|
784
|
+
- `where` / `additionalWhere` — `expr(...)`, an `expr[]` (ANDed), or a raw `Value`. Rides `context.search`.
|
|
785
|
+
- ⚠ `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.
|
|
786
|
+
- 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.
|
|
787
|
+
- Compose nested boolean logic with `and(...)` / `or(...)` groups (also available on `addon()` `where`).
|
|
788
|
+
- 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.
|
|
789
|
+
- ⚠ 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.
|
|
790
|
+
- `bind: [{ table, as?, join?, where? }]` — joins (`context.bind[]`). `join` defaults to `"inner"`. `as` defaults to the table name; two joins to the same table need distinct aliases.
|
|
791
|
+
- ⚠ In `where`/`sort`/`eval` a JOINED column takes a dotted path (`col("team_row.id")`); THIS query's own columns stay **bare** (`col("team")`). Qualifying your own by table name needs `tableAlias` (same rule as `aggregate`) — without it the engine reads the operand as text and 400s `ParseError: Invalid value for param` naming the OTHER operand, so it throws at export instead.
|
|
792
|
+
- `bind: [{ table: team, as: "team_row", join: "left", where: expr(col("team"), "=", col("team_row.id")) }]`
|
|
793
|
+
- `returnType` — `"list"` (default) | `"single"` | `"count"` | `"exists"` | `"stream"` | `"aggregate"`. Drives `context.return.type` AND the `InferResponse` shape: `count`→`number`, `exists`→`boolean`, `single`→`Row|null`, `stream`→`Row[]` (pageable, no envelope), `list`→`Row[]`/envelope, `aggregate`→rows keyed by the `aggregate.group`/`eval` aliases. ⚠ A bare `count` of ZERO serializes as an EMPTY body, not `0` — a client parsing JSON gets a parse error on the one result it most needs to handle. Wrap it: `response: { count: ref("n") }`.
|
|
794
|
+
- `eval: [{ name, as, filters? }]` — computed columns (`context.eval[]`). Each `as` grafts onto the row as an `unknown` key in `InferResponse`; shadowing a real column throws. Write `name` **bare** (`"embedding"`) — it is alias-qualified on emit exactly like `aggregate` (a bare eval name is `Unsupported param format` at runtime), and the statement declares the alias it used. An `as` alias is `sort`able in the SAME query.
|
|
795
|
+
- An `eval`/`sort`/`where` filter pipeline compiles to **SQL**, so it resolves a DIFFERENT registry than `fl.*` (which runs in the request): the vector family, geo `distance`/`within`/`covers`, `search_rank`, the aggregators. Exported as `QUERY_EXPRESSION_FILTERS`/`VECTOR_FILTERS`.
|
|
796
|
+
- **Vector similarity search** — the ONLY way to query an `f.vector` column (no `SearchOp` does distance). `eval: [{ name: "embedding", as: "distance", filters: [{ name: "vector_cos_distance", arg: [inp("q")] }] }]` + `sort: [{ sortBy: "distance", dir: "asc" }]` ranks in the DATABASE over the column's index. Match the filter to the index `op` (`vector_cos_distance`↔`vector_cosine_ops`, `vector_l2_distance`↔`vector_l2_ops`, `vector_l1_distance`↔`vector_l1_ops`, `vector_inner_product`↔`vector_ip_ops`); `vector_cos_similarity` is the inverse, so sort it `desc`. The same filter on a `where` operand cuts off BY distance instead of by row count.
|
|
797
|
+
- `aggregate: { group?, eval?, sort?, paging? }` (with `returnType:"aggregate"`) builds `context.return.aggregate`. `group`/`eval` are `{ name, as, filters? }`, an aggregator like `sum`/`count` riding `filters`. Some aggregators resolve ONLY here, not in a runtime value pipeline: `count_distinct`, `median`, `to_list`/`to_distinct_list` (each with `_asc`/`_desc`), and `vector_distance`.
|
|
798
|
+
- ⚠ Write each `name` as a **bare** column (`"status"`). It is alias-qualified to `"<alias>.status"` on emit — the engine rejects an unqualified column in an aggregate with `Unsupported param format`. An already-dotted `name` (a `bind`ed/joined column) passes through.
|
|
799
|
+
- The alias it qualifies WITH is `tableAlias` when you set one, otherwise the table's name — and the statement DECLARES that alias (`dbo.as`) so the qualified name resolves. Nothing to do by hand; a bare `name` is the form to write.
|
|
800
|
+
- `sort: [{ sortBy: <col>, dir?: "asc"|"desc"|"rand" }]` and `paging: { page?, per_page?, offset?, totals?, metadata?, search?, sort? }` ride `context.return.list`.
|
|
801
|
+
- ⚠ `paging` with a page/per_page/offset field and `metadata` on (the DEFAULT) wraps the result in an envelope `{ items: Row[], curPage, nextPage, prevPage, offset, perPage, itemsReceived }` — plus `itemsTotal`/`pageTotal` when `totals: true` — instead of a bare `Row[]`. `InferResponse` reflects it. Pass `metadata: false` to keep the bare array.
|
|
802
|
+
- Read `nextPage` (`number|null`) as the typed has-next signal.
|
|
803
|
+
- **Input-bound paging:** `page`/`per_page`/`offset` also accept a `Value` (`inp("page")`), riding `context.simpleExternal` while the static block stays the engine gate (`enabled:true`). `paging.search`/`sort` are `Value` dynamic overrides.
|
|
804
|
+
- A `search`/`sort`-only `paging` (no numeric field) does NOT paginate.
|
|
805
|
+
- `external: { value, permissions? }` — the classic whole-config blob (forces the gate on). It falls back to input-bound `paging` when it resolves empty, so supplying both is valid.
|
|
806
|
+
- `distinct` — `"auto"` (default) | `"yes"` | `"no"`, riding `context.return.<list|stream>.distinct`.
|
|
807
|
+
- `s.db.truncate({ table, reset?, as? })` · `s.db.schema({ table, path, as? })`.
|
|
808
|
+
- `s.db.direct_query({ sql, responseType?, args?, parser?, as? })` — `sql` is a **raw string** (not a `Value`); binds go in `args: Value[]`. `parser: "template_engine"` renders the body as a template first — how a query interpolates a column or table name a bound arg cannot carry; omit it for the default.
|
|
809
|
+
- `s.db.external.<engine>.direct_query({ sql, connectionString, responseType?, args?, parser?, as? })` — same shape against an EXTERNAL database; `<engine>` is `postgres`/`mysql`/`mssql`/`oracle`/`snowflake`. `connectionString` is a `Value` — reach for `env(...)`, not a literal — stored as `context.connection_string_flex`. A bare string stores the older `context.connection_string` instead (an env-var name unless it looks like a URL); each form round-trips as itself.
|
|
810
|
+
- `s.db.transaction({ body, as? })` — run a `Statement[]` atomically. `as` binds whatever the block returned.
|
|
811
|
+
- `s.db.bulk.add({ table, items, allowIdField?, as? })` / `s.db.bulk.update` / `s.db.bulk.patch` — `items` is an array `Value`.
|
|
812
|
+
- ⚠ `bulk.add` **drops `id` on every row unless `allowIdField: true`** (silently, next sequence value instead) — the opposite of `seed`, where `id` pins. Rows referenced by a foreign key need `allowIdField: true`; literal `items` carrying `id` without it throw. `bulk.update`/`patch` keep `id` (their match key).
|
|
813
|
+
- ⚠ **`bulk.update` is a whole-row REPLACE: every column an item OMITS is zeroed** (`""`/`0`/`null`), HTTP 200, no error — `{ id: 7, status: "done" }` blanks the rest of row 7. **Use `s.db.bulk.patch`** for the partial write "update these rows" means. `export()` warns on a STATIC `items` missing columns (`--strict` fails); a `ref`/`inp` `items` is uninspectable.
|
|
814
|
+
- `s.db.bulk.delete({ table, where?, allRows?, as? })` — deletes rows by a `context.search` filter. `where` is the same surface as `s.db.query` (`expr(...)`/`cmp(...)`, `and(...)`/`or(...)` groups, an array of those ANDed, or a raw `Value`) and encodes through the identical `{expression:[…]}` search shape. ⚠ A filter that constrains nothing deletes **every** row, so a missing or empty `where` **throws**: pass the filter, or `allRows: true` for a deliberate wipe (both together also throw). `allRows` emits the empty search the engine requires and returns the deleted count; reach for `s.db.truncate({ table, reset: true })` when the id sequence should restart too.
|
|
815
|
+
|
|
816
|
+
# Statement runtime behavior
|
|
817
|
+
|
|
818
|
+
> 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.
|
|
819
|
+
|
|
820
|
+
Runtime behavior (what the `as:` output holds, and misses):
|
|
821
|
+
|
|
822
|
+
- `db.get` binds **`null`** when no row matches (it does NOT throw) — so the output is `InferRow<typeof table> | null`; null-check it. On a hit it binds the **full row**. (`db.has` is the boolean existence test.)
|
|
823
|
+
- `db.edit` binds the **full, post-mutation row** (the freshly-written values, not the pre-edit ones). `db.add` binds the **full inserted row**, including the auto-assigned `id` and `created_at`. So `InferRow<typeof table>` is the right response type for those two. `db.del` **binds `null`** — the engine deletes the row and returns no value, so don't return the `as` var expecting the deleted row.
|
|
824
|
+
- Unlike `db.get`, `db.edit` and `db.del` **throw** `NotFound` (HTTP 404) when no row matches the field. `db.add` throws on a unique-constraint violation.
|
|
825
|
+
- **`InferResponse<typeof query>`** derives an endpoint's response type (read-side round trip, no codegen). It resolves object-literal responses to those keys; a `response: ref("x")` returning a variable bound by a TOP-LEVEL db op on a `table()` resolves to that op's result:
|
|
826
|
+
| statement | resolves to | on a miss |
|
|
827
|
+
|---|---|---|
|
|
828
|
+
| `db.add` / `db.edit` / `db.patch` / `db.add_or_edit` | `Row` (the full written row, non-nullable) | throws — `NotFound`/404 for `edit`/`patch`, a unique-constraint error for `add`; `add_or_edit` upserts and never misses |
|
|
829
|
+
| `db.get` | `Row \| null` | binds `null` rather than throwing |
|
|
830
|
+
| `db.query` / `db.bulk.patch` | `Row[]` | — |
|
|
831
|
+
| `db.has` | `boolean` | — |
|
|
832
|
+
| `db.bulk.delete` | `number` (count) | — |
|
|
833
|
+
| `db.del`, `db.bulk.add`/`bulk.update`, raw `direct_query` | `unknown` (the engine leaves them untyped) | — |
|
|
834
|
+
- A `get`/`query` `output: [...]` selection narrows to a `Pick` (still `| null` for `get`). A dotted `ref("row.col")` into a `db.get` row projects that column carrying the `| null` (→ `Col | null`).
|
|
835
|
+
- A value reshaped by a filter/lambda, or a variable built by control flow / `set_var`, also resolves to `unknown`.
|
|
836
|
+
- A CALL binds the TARGET's response type: `s.function.call`/`s.function.run`/`s.api.call`/`s.tool.call` given a def HANDLE brand their `as` with `InferResponse` of the target, so `ref("out.field")` types to that field. What the target resolves to is what propagates — declare `responseShape` on the TARGET to fix every caller at once. A target named by STRING has no def to read and stays `unknown`, as does an async `s.function.run` (it binds a job handle, not the result).
|
|
837
|
+
- ⚠ In an object-literal response the KEYS are always known, but a VALUE is typed only when it traces to a binding — a `ref`, or a `ref` under `withFilters`. A CONSTANT does not trace: `response: { success: c.bool(true), hello: c.text("hi") }` derives `{ success: unknown, hello: unknown }`, NOT `{ success: boolean, hello: string }`. Same for an `inp` outside a trace. Only a reference carries a type the derivation can follow. Declare `responseShape` when a client needs those keys typed.
|
|
838
|
+
- A NESTED member resolves to any depth by these same rules, in either spelling — `{ user: obj({ id: ref("row.id") }) }` and the raw literal `{ user: { id: ref("row.id") } }` both derive `{ user: { id: Col | null } }`. A raw scalar member types itself (`{ count: 3 }` → `number`).
|
|
839
|
+
- Runs on every response-bearing kind — `query`, `defineFunction`, `realtimeMessage`, `tool`, `middleware`, and the response-bearing triggers — each of which also accepts `responseShape`. A trigger builds its stack and response through CALLBACKS (`stack: (t) => [...]`), and the trace follows through them.
|
|
840
|
+
- Close any `unknown` by declaring `responseShape` on the def (`responseShape: null as InferRow<typeof t> | null`) — the declaration ALWAYS overrides derivation. It is available on EVERY kind listed above, `query` included.
|
|
841
|
+
- The shapes that never derive, and the one line that closes each: rows built in `s.lambda` (JS is opaque to the walk) → `responseShape: [] as Row[]`; a var assigned across `s.conditional`/`s.switch` branches → `responseShape` for the union you meant; a call whose target is named by STRING, or an async `s.function.run` → give the TARGET a `responseShape` (a handle then carries it) or declare it here. Reach for `responseShape` as the FIRST move on these, not after fighting the derivation.
|
|
842
|
+
- ⚠ A RUNTIME guard does not narrow the STATIC type. A `s.precondition` (or a conditional) proving a `db.get` row non-null leaves `InferResponse` at `Row | null`, because the walk reads the stack's shape and not its control flow — the `| null` is correct about the derivation and stale about your stack. Declare `responseShape` to drop it rather than asserting at every call site.
|
|
843
|
+
- ⚠ A `resultStrategy: "replace"` middleware attached `post` reshapes the endpoint's output at runtime, which the static walk cannot see. Declare `responseShape` when a post middleware rewrites the response.
|
|
844
|
+
- ⚠ **Spreading a `Statement[]` helper into a stack kills the whole walk.** The trace needs the stack's TUPLE type, so `...myHelper()` where the helper returns `Statement[]` widens it and EVERY `as` in that stack — including ones declared after the spread — stops resolving. The response then types as `StackTupleWidened`, whose name says so. Fix: return `statements(s.a(...), s.b(...))` from the helper (a const-generic identity export — the tuple survives the spread). A helper that builds its array in a LOOP cannot be a tuple; declare `responseShape` there.
|
|
845
|
+
- **Addons** enrich returned rows. `db.query`/`get`/`add`/`edit`/`patch` accept `addon: [{ addon, as, input?, output?, children? }]`; `db.add_or_edit`/`del`/`has`/`truncate` take no `addon`.
|
|
846
|
+
- `addon` is the target (name or def handle). `as` is the destination on the row — a bare alias (`"_user"`) or a dotted `offset.alias`, authored relative to a row. Under a metadata paging envelope the `items[]` offset is prefixed automatically; writing it yourself is tolerated and not double-prefixed.
|
|
847
|
+
- `input` maps addon inputs — bind a parent-row column with `out(col)`. `output` restricts addon columns. `children` nests addons.
|
|
848
|
+
- An addon is a single table-bound db query, NOT a statement stack: `addon({ name, table, tableAlias?, where?, sort?, output: [cols], cardinality?: "single"|"list"|"count"|"exists"|"aggregate", group?, eval?, input?, context? })`, registered via `registerAddons([...])`.
|
|
849
|
+
- `table` auto-fills the `context.dbo` binding. ⚠ Never author `table: null` — that is a BROKEN table-less addon returning nothing; `codegen` emits it only for an already-broken pulled object.
|
|
850
|
+
- `tableAlias` is its SQL alias (`context.dbo.as`), qualifying `where`/`sort` columns (`col("merchant.id")`).
|
|
851
|
+
- `where`/`sort` take the same surface as `s.db.query` and encode `context.search`/`context.sort`. `where` is the predicate binding the addon to the parent row — `expr(col("id"), "=", inp("user_id"))`.
|
|
852
|
+
- `cardinality` shapes the result (`context.return.type`, omitted for the `"list"` default). Rarer context (`eval`/`bind`/`lock`) stays raw `context` passthrough.
|
|
853
|
+
- Attaching a typed `addon({ table, output })` handle merges its alias (the last `as` segment) onto the row in `InferResponse`: `{cols}` for `single`, `{cols}[]` for `list`, `number` for `count`, `boolean` for `exists`, and for `aggregate` an array keyed by the `group`/`eval` aliases (`unknown` values; `unknown` when neither is declared).
|
|
854
|
+
- An attachment-level `output` narrows an object/array graft further. A bare-NAME reference grafts `unknown` — narrow it at the call site.
|
|
855
|
+
- ⚠ An alias that shadows an existing column on the queried table throws at build time; rename with a `_` prefix.
|
|
856
|
+
- **Middleware attachment** runs a reusable `middleware({...})` before/after a host's own stack. Attach with the host's `middleware: { pre, post }` field on `query`/`function`/`task`/`tool`/`apiGroup` (NOT triggers): each phase is an ordered list of middleware refs (def handle or name), or `{ middleware, active: false }` to keep an entry disabled. Providing a phase **overrides** it (sets the stored `pre_customize`/`post_customize` flag); omitting a phase **inherits** the parent tier's chain — the engine resolves Query → API Group → Workspace at request time (override, not merge; the API-Group tier applies to queries — functions/tasks/tools have no API-group binding and inherit straight from the workspace). Prefer a def handle over a bare name when the middleware pins an explicit `guid`. `pre: middleware.clear()` (an empty list) overrides with nothing — stop inheriting. Workspace-level defaults are the terminal tier: `workspaceConfig({ middleware: { query: { pre }, function, task, tool } })` emits the flat `{host}_{phase}` map (no `_customize` flags) — setting it replaces the whole workspace map, so unlisted hosts are cleared; omit the field to leave existing workspace middleware untouched. Distinct from `s.middleware.call` (inline invoke).
|
|
857
|
+
- **Middleware request context.** A `pre` middleware runs **after** auth resolution, so `auth()` is available inside the middleware when the host is authenticated (its `auth` names an auth table); on a public host `auth()` is `null`. This matters for the canonical use — a rate limit keyed by `auth("id")`: on an authenticated endpoint the bucket is per-user, but attach the same middleware to a public endpoint and every anonymous caller keys under the same `null` id (one shared bucket), silently. To catch that, `export()` **warns** (never blocks) when a middleware whose stack references `auth()` is directly attached to a host where `auth()` may be null — a `query` with no auth table, a `task` (scheduled, never authenticated), or a `function`/`tool` (whose auth is caller-dependent). An authenticated query (its own `auth` table set) is skipped. The check is direct-attachment only; a middleware reaching a public query via API-group/workspace tier inheritance is not caught.
|
|
858
|
+
- **Rate-limit recipe (the canonical middleware).** Per-user rate limiting is the most common middleware. Author it with `s.redis.ratelimit` and a **composite key** built via the filter chain — `"prefix" + auth("id")` does not exist, you build the key: `middleware({ name: "write_rl", exceptionPolicy: "rethrow", stack: [ s.redis.ratelimit({ key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), max: c.int(10), ttl: c.int(30), error: c.text("Too fast.") }) ] })`. `exceptionPolicy` defaults to `"rethrow"`, which is what makes a tripped limit abort with HTTP 429; `"silent"` would let the over-limit request through. Attach it with `middleware: { pre: [writeRl] }` on an **authenticated** host (its `auth` set) so `auth("id")` keys per-user; on a public host `auth("id")` is null and every caller shares one bucket (`export()` warns — see request context above). **Shared-bucket rule:** co-attaching one middleware object to N hosts means all N share the *same* key ⇒ *one* counter — `max: 10` is a global per-user budget across them, not 10-per-host. Vary the key (fold in the host/action name) for an independent limit per host.
|
|
859
|
+
- **Middleware `exceptionPolicy`** governs what a **throw** in the middleware stack does to the request (XanoTS passes the value through; the Xano engine interprets it). `"rethrow"` is the **default** — the throw aborts the request and surfaces the authored `error`/status (a tripped `s.redis.ratelimit` → HTTP 429); the `post` chain still runs. `"silent"` swallows the throw, so a guard set to it is **not enforced** — advisory middleware only. `"critical"` is `"rethrow"` plus skipping the `post` chain. The only difference between `rethrow` and `critical` is whether `post` runs — no status or logging change.
|
|
860
|
+
|
|
861
|
+
- **Request history** controls per-object execution capture (the request/task/trigger debugger). Authored as a single scalar `history` field on any primitive: `false` off, `true` on at the default capture depth, a number = capture depth (how many statement executions are recorded per history record — NOT record retention), `"all"` unlimited. **Omit `history` to inherit** — the engine resolves object → container → workspace at request time (a query inherits from its API group, a tool from its toolset envelope, everything else straight from the workspace). Any authored value stops inheriting for that object. Per-kind defaults (when inheriting): query/task/tool capture ON, function/trigger/middleware OFF; default depth 100. Container tiers are authorable too — `apiGroup({ history })` sets the `query_*` default its queries inherit, and an agent/mcp_server/toolset `history` sets the `tool_*` default its tools inherit. Workspace-level defaults are the terminal tier: `workspaceConfig({ history: { query, function, task, tool, trigger, middleware } })` emits the flat `{objType}_enabled`/`{objType}_limit` map (no inherit flag) — setting it is wholesale (unlisted types fall back to their engine default), so declare every default you want to keep; omit the field to leave existing workspace history untouched.
|
|
862
|
+
|
|
863
|
+
- **Workspace environment variables** set a tenant's env vars through the workspace object: `workspaceConfig({ env: { STRIPE_KEY: process.env.STRIPE_KEY!, APP_BASE_URL: "https://…" } })`. Author them as a name→value MAP. Read a var back with `env("NAME")` (→ `$env.NAME`), which compiles to tag "setting" with the plain name. Values are SECRETS: prefer sourcing from `process.env` over committing literals, and don't commit a compiled bundle with real values. `deploy` REPLACES the tenant's env with the declared map; `release` (merge) is ADD-ONLY — it creates missing keys but does NOT update or remove existing ones, so changing a value in code and releasing leaves the live value unchanged. Omit `env` to leave existing env untouched. The separate `settings` field is a plain object.
|
|
864
|
+
|
|
865
|
+
# Auth, cross-object calls, and microservices
|
|
866
|
+
|
|
867
|
+
> Read when the stack authenticates, calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), or reaches a microservice.
|
|
868
|
+
|
|
869
|
+
Auth & calls:
|
|
870
|
+
|
|
871
|
+
- `s.security.create_auth_token({ table, id, extras?, expiration?, as? })` — `extras` defaults to `{}`, `expiration` to `86400`s (`0` = never).
|
|
872
|
+
- `s.security.create_guid({ as? })` — bind a fresh GUID string. Takes nothing else.
|
|
873
|
+
- `s.function.run({ fn, input?, as?, runtime? })` / `s.function.call({ fn, input?, as? })` — run another function; `input` is keyed by the target's input names.
|
|
874
|
+
- `runtime?` runs it in the BACKGROUND: `{ mode: "async-shared" }` or `{ mode: "async-dedicated", cpu?, memory?, timeout?, maxRetry? }` (resources read at dedicated only). An async call DOES NOT return the result — it dispatches and continues, so `as` binds nothing; collect with `s.await({ ids })`. Omit for a normal call. Same block on `s.ai.agent.run`.
|
|
875
|
+
- `s.api.call({ api, input?, headers?, auth?, as? })` — invoke an endpoint. `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.
|
|
876
|
+
- `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`.
|
|
877
|
+
- `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).
|
|
878
|
+
- `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).
|
|
879
|
+
- `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.
|
|
880
|
+
- `s.action.call({ action, package?, input?, as? })` / `s.action.package.call(…)` — invoke a marketplace action; `package` is its slug.
|
|
881
|
+
- `s.cloud.job({ image?, command?, args?, secret?, template?, await?, as? })` — launch a containerized job. ⚠ `await` is SECONDS (default 60), not a boolean.
|
|
882
|
+
- `s.cloud.job.status({ id, as? })` · `s.cloud.job.await({ ids, timeout, as? })` — poll one job, or block on several (`ids` a list `Value`, `timeout` in seconds; both required).
|
|
883
|
+
|
|
884
|
+
Microservices (the `microservice()` def and the statement that calls it):
|
|
885
|
+
|
|
886
|
+
- `s.microservice.request({ host, path, port?, method?, params?, headers?, timeout?, follow_location?, as? })` — in-cluster microservice call (`mvp:microservice_request`); no TLS fields. ONLY `host`+`path` required; the rest default to the engine's values (`GET`/`{}`/`[]`/`10`/`true`), always emitted. Pass the `microservice()` DEF as `host` — it binds by NAME (how the engine resolves it), so a rename fixes every call site and the port is checked before deploy. `port?` folds into `host` as `"name:port"`: a def exposing ONE `servicePort` resolves automatically, SEVERAL requires it. A raw `"name:port"` string works, unvalidated, and is the only way to reach an instance-level microservice. `tenantDeploy: "manual"` on the def imports the row without starting the workload.
|
|
887
|
+
- `s.workflow_test.call({ workflowTest, datasource?, as? })` — run another workflow test from inside one, which is the only place it runs (see Gotchas in `llms.txt`). The odd one out: NO `input` (a workflow test takes none), and it carries `datasource?` instead — same clone caveat as the kind's own field.
|
|
888
|
+
|
|
889
|
+
# Value catalog
|
|
890
|
+
|
|
891
|
+
> 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.
|
|
892
|
+
|
|
893
|
+
- `c.text(s: string) => Value` — String constant → tag "const".
|
|
894
|
+
- `c.int(n: number | bigint | string) => Value` — Integer constant → tag "const:int". The engine stores integers as STRINGS and has no 53-bit limit, so pass a string or bigint for anything past Number.MAX_SAFE_INTEGER — c.int("18446744073709551615") is exact where the number literal for it is already …616. A number that is not a safe integer THROWS rather than encoding the rounded value.
|
|
895
|
+
- `c.decimal(n: number | string) => Value` — Decimal constant → tag "const:decimal". Pass a string only to keep a stored spelling a number cannot reproduce (c.decimal("10.00") keeps its trailing zeros).
|
|
896
|
+
- `c.blank(tag: "const:<type>") => Value` — The editor's UNCONFIGURED value box (stored value ""), emitted by codegen for a pulled workspace — do not author it. NOT a zero or an empty collection: the engine reads "" and "0" differently, so c.blank("const:int") ≠ c.int(0) and neither canonicalizes into the other. Constant tags except const/const:obj, whose blanks are c.text("")/c.obj(null).
|
|
897
|
+
- `c.bool(b: boolean) => Value` — Boolean constant → tag "const:bool".
|
|
898
|
+
- `c.null(tag?: "const:null" | "const:obj") => Value` — Null constant, stored value "null". Bare c.null() is tag "const:null". c.null("const:obj") is the OBJECT-TYPED null the engine writes into a db.* statement's @meta slot — different stored bytes from c.obj(null), which is the blank object (value ""), though both evaluate to null. Codegen emits whichever spelling the workspace stored; do not swap one for the other.
|
|
899
|
+
- `c.obj(o?: Json | null) => Value` — Object constant → tag "const:obj". A populated one stores an empty {} carrying one `set` filter per key — the editor's form, and the only populated form the engine reads back (a populated JSON string arrives truncated and fails the request with ERROR_FATAL "Unable to decode."). ⚠ a ZERO-BASED numeric key is an INDEX in the engine's data model, so c.obj({"0":"a"}) evaluates to the list ["a"] (a non-zero-based one like {"2":…} survives as a key) — that is the platform, not this encoding. No argument = the empty object {} — use this one. Explicit null = the legacy blank form the engine evaluates to null, NOT {}; it exists only so a pulled workspace round-trips, do not author it. Plain JSON literals only — a nested tagged value (inp/ref/auth/c.*) is rejected; for a computed object response use a record of values, not c.obj.
|
|
900
|
+
- `c.array(a: Json[]) => Value` — Array constant (JSON string) → tag "const:array". Plain JSON literals only — a nested tagged value is rejected, same as c.obj.
|
|
901
|
+
- `c.expression(source: string) => Value` — Xano Expression Engine source, passed through VERBATIM → tag "const:expr2". The string IS the expression: c.expression('"Hi, " ~ $input.name'), c.expression("$var.price * $var.qty"). ⚠️ NOT VALIDATED — never parsed or type-checked, invisible to InferResponse, and untouched by a rename that updates every typed ref(); a typo surfaces at runtime or as a wrong answer. Use it ONLY for syntax the typed surfaces cannot express (~ concatenation, inline arithmetic, conditionals) — prefer ref/inp/col, withFilters+fl.*, and obj() (which BUILDS a checked expression). Not the expr() condition builder.
|
|
902
|
+
- `c.now() => Value` — Current time as epoch-ms — the engine-native const:epochms constant (no filter). Valid inline as a where/cmp operand. For cutoff math (cutoff = now - max_age) either compare inline or, for reuse/readability, hoist it into an s.set_var and compare against the var.
|
|
903
|
+
- `obj(fields: Record<string, Value | nested>) => ObjValue<typeof fields>` — Dynamic object value → tag "const:expr2" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/col values, env()/setting()/sys.*, c.now(), c.* constants, nested records, or arrays — and each member may carry a FILTER CHAIN (withFilters + fl.*), which renders as the expression pipe `$var.row|get:"a.b"`. That matters most for the null-safe drill: db.get binds null on a miss, so ref(path, { safe: true }) inside an obj() is the normal shape, not a workaround — you do NOT need a preceding s.set_var to hoist it. The member record rides the return type, so InferResponse resolves each member the way it resolves a top-level response key — `response: { user: obj({ id: ref("row.id") }) }` derives `{ user: { id: Col | null } }`, and a raw nested object literal (`response: { user: { id: ref("row.id") } }`, auto-wrapped through this) derives the same. NEST WITH A RAW RECORD, not an inner obj() call: an inner call yields a const:expr2 value, which the expression serializer has no spelling for and THROWS. Still rejected: a filter ARGUMENT carrying its own chain (a trailing | binds to the whole value, not one argument), a DISABLED filter (an expression string cannot record that), and the output/response/toolset/reg tags — build those in a prior step and ref() them. Use for e.g. s.ai.agent.run args.
|
|
904
|
+
- `ref(name: string, opts?: { safe?: boolean }) => Value` — Reference a stack variable → tag "var". Pass { safe: true } for null-safe nested access — a dotted ref("owner.user_id", { safe: true }) compiles through the get filter so it resolves to null instead of raising "Unable to locate var" when the base is null.
|
|
905
|
+
- `inp(name: string) => Value` — Reference a function/endpoint input → tag "input". Resolves ONLY against the `input` block of the def it sits in — a value produced earlier in the stack is `ref("var.field")`, not `inp("field")`. A name that is not declared here deploys clean and fails at runtime with ERROR_FATAL "Unable to locate input: <name>" on every branch that reads it; `export()` warns, and `--strict` fails the build. Sending the name in the request does NOT rescue it — an undeclared input is never bound, so the call fails identically with the value present. A dotted path drills INTO a declared input (`inp("action.amount")` needs a declared `action`).
|
|
906
|
+
- `col(name: string) => Value` — Reference a table column → tag "col".
|
|
907
|
+
- `auth(path?: string) => Value` — Reference the authenticated identity (auth("id") → $auth.id) → tag "auth".
|
|
908
|
+
- `caught(path?: "code" | "message" | "name" | "result") => Value` — Read the caught error inside an s.try_catch CATCH arm → tag "trycatch". Valid ONLY there — it reads empty in the try/finally arms and outside the statement. Those four fields are all the engine binds (result is the attached payload); bare caught() is the whole error record. ⚠ For an ENGINE-raised exception only `code` and `name` are populated; for an `s.throw`, `message` is the fixed string "Throw Error Statement" and your text is in `result`. So `caught("name")` is useful in both cases and `caught("message")` in NEITHER.
|
|
909
|
+
- `toolset(path: "token" | "params" | `params.${string}`) => Value` — Read a toolset-scoped binding inside a tool → tag "toolset". The engine binds two: token (the calling URL's token, null when absent) and params (its parameters, as an object); a dotted params.<key> reads one parameter out of that object. Bound only while a tool runs under its toolset — anywhere else it reads empty.
|
|
910
|
+
- `env(name: string) => Value` — Read a WORKSPACE environment variable (set via workspaceConfig({ env }) or the dashboard) → `$env.NAME`. Compiles to tag "setting" with the plain name. env("remote_ip") reads a user var named remote_ip, not the caller IP — use sys.remoteIp() for that.
|
|
911
|
+
- `setting(name: string) => Value` — Reference a workspace setting → tag "setting". Built-in system vars are $-prefixed settings, e.g. setting("$remote_ip"); prefer the typed sys.* accessors.
|
|
912
|
+
- `sys.*() => Value` — Built-in system / request-context variables → tag "setting" ($-prefixed). Accessors: remoteIp, requestMethod, requestUri, requestQueryString, httpHeaders, requestAuthToken, apiBaseUrl, datasource, branch, tenant, release, platform, isDebugger. In XanoScript these are $env.$remote_ip etc.; sys.remoteIp() is the public-endpoint rate-limit key (auth("id") is null there).
|
|
913
|
+
- `filter(name: string, ...args: Value[]) => FilterXdo` — Build a filter-chain entry by raw name (escape hatch).
|
|
914
|
+
- `fl.*(...args: Value[]) => FilterXdo` — Typed value-pipeline filters; see the `filters` catalog.
|
|
915
|
+
- `withFilters(value: Value, ...filters: FilterXdo[]) => Value` — Attach a filter chain to a value (filters passed spread; an array is also accepted).
|
|
916
|
+
|
|
917
|
+
Tags: const, const:int, const:decimal, const:bool, const:array, const:obj, const:null, const:epochms, const:expr, const:expr2, var, input, auth, env, setting, col, output, response, trycatch, toolset.
|
|
918
|
+
|
|
919
|
+
# Column and input types
|
|
920
|
+
|
|
921
|
+
> 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.
|
|
922
|
+
|
|
923
|
+
Author table columns + function/API inputs with the typed catalog: `f.<type>(opts?)`
|
|
924
|
+
for columns, `input.<type>(opts?)` for inputs. Common opts: `required`, `nullable`,
|
|
925
|
+
`default`, `description`.
|
|
926
|
+
**`nullable` defaults PER TYPE, matching the engine's own column-creation API**: `true`
|
|
927
|
+
for `f.vector`, `f.uuid`, every `f.geo.*` and every file type (`f.image`/`f.video`/
|
|
928
|
+
`f.audio`/`f.attachment`), `false` for everything else (text, int, decimal, bool, email,
|
|
929
|
+
enum, json, object, password, date, tableRef). Pass `nullable` explicitly to override —
|
|
930
|
+
e.g. `f.geo.polygon({ nullable: false })`. This is why `f.vector(8)` deploys: the engine
|
|
931
|
+
turns an empty default into SQL NULL only for a nullable column, so a non-null vector
|
|
932
|
+
would reach PostgreSQL as `vector(8) not null default ''` and fail to create.
|
|
933
|
+
**`f.geo.*` values are `{ type, data }`, not GeoJSON.** The same shape goes in and comes
|
|
934
|
+
back: `{ type: "point", data: { lng, lat } }`, `{ type: "poly", data: [{ lng, lat }, …] }`
|
|
935
|
+
— `type` is the engine's abbreviation, and a polygon ring is closed for you. Raw WKT text
|
|
936
|
+
(`c.text("POINT(1 2)")`) is accepted on write too, but a read never returns one.
|
|
937
|
+
`methods` is a bind-time validator/transform pipeline whose
|
|
938
|
+
valid names depend on the field type (below) — pass bare names (`"trim"`), the
|
|
939
|
+
colon-form with args (`"min:8"`), or `{ name, arg }` for anything not listed.
|
|
940
|
+
`f.json({children})` declares the nested shape stored INSIDE a json column — an ARRAY of
|
|
941
|
+
`{name, type, methods?, children?}`, order-significant, distinct from the `FieldMap` that
|
|
942
|
+
`f.object` takes positionally. Omit it for an unstructured json column.
|
|
943
|
+
`f.enum(values)`/`f.vector(size)`/`f.object(children)`/`f.tableRef(table)` take a
|
|
944
|
+
positional payload before opts — and still accept the standard `FieldOptions` after it
|
|
945
|
+
(`f.enum([])`/`input.enum([])` are accepted, because the engine stores an enum whose
|
|
946
|
+
options were never filled in — that is a pulled-workspace shape, not one to author; it
|
|
947
|
+
brands the column `never`, which `InferRow` surfaces at read time as `undefined` — so
|
|
948
|
+
code written against `never` is dead in a way `undefined` is not.)
|
|
949
|
+
(e.g. `f.tableRef(users, { required: true })` — only `min`/`max` are listed as tableRef
|
|
950
|
+
methods below, but `required`/`nullable`/`description`/… apply like any field.)
|
|
951
|
+
An **OPTIONAL foreign key wants a `0` sentinel, not `nullable: true`.** `f.tableRef` stores
|
|
952
|
+
an `int`, and a null in it is unqueryable: `null` is never a legal `fieldValue`/`id`, so
|
|
953
|
+
`s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather
|
|
954
|
+
than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for
|
|
955
|
+
"not set yet" — `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and
|
|
956
|
+
binds `null`, which is the answer the null was reaching for. `export()` warns on a literal
|
|
957
|
+
`c.null()` in that slot.
|
|
958
|
+
An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through
|
|
959
|
+
any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:
|
|
960
|
+
"vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see
|
|
961
|
+
`s.db.query` → `eval`). Without that pairing the column stores and indexes but nothing
|
|
962
|
+
queries it.
|
|
963
|
+
`{ array: true }` makes any `f.*` scalar a **list column** — `f.text({ array: true })`
|
|
964
|
+
surfaces as `string[]` in `InferRow<typeof table>` (the column analogue of `input.list`).
|
|
965
|
+
A **column `default` must stay within the BMP** — a 4-byte character (codepoint > U+FFFF,
|
|
966
|
+
e.g. an emoji) is mangled into invalid UTF-8 by the engine's default pipeline and is rejected
|
|
967
|
+
at export rather than 500ing at deploy (Postgres `22021`); BMP defaults (accents, `€`, most
|
|
968
|
+
CJK) are fine, or put the value on an `input.<type>({ default })`, applied at runtime bind.
|
|
969
|
+
`input.*` mirrors `f.*` — every column type below is
|
|
970
|
+
a legal input (scalars, files `input.image/video/audio/attachment`, `input.geo.*`,
|
|
971
|
+
`input.vector(size)`, `input.tableRef(table)`, `input.object(children)`), plus
|
|
972
|
+
`input.dbLink(table)` is the odd one: ONE entry that EXPANDS into one input per
|
|
973
|
+
COLUMN of the linked table, so read them by column name (`inp("email")`), never by
|
|
974
|
+
the entry's own name. `hidden: ["created_at"]` drops columns from that expansion.
|
|
975
|
+
`input.list(element)` for arrays — wrap any element constructor, e.g.
|
|
976
|
+
`input.list(input.text())` or `input.list(input.object({ id: f.int() }))`. Prefer the
|
|
977
|
+
typed forms over `input.json()` when the shape is known.
|
|
978
|
+
**Typed inputs validate/coerce on bind, before your stack runs** — so reach for the
|
|
979
|
+
specific type instead of hand-rolling checks. `input.email({ required: true })` rejects a
|
|
980
|
+
malformed address with a 400 (and trims; add `methods: ["lower"]` to downcase) — no
|
|
981
|
+
`regex_matches` needed; `input.int`/`input.decimal`/`input.uuid`/`input.enum([...])`/`input.date`
|
|
982
|
+
likewise reject or coerce bad input at the boundary. Drop to `input.text` + `s.precondition`
|
|
983
|
+
only for rules no type expresses.
|
|
984
|
+
⚠ `input.url` is NOT one of them — there is no engine `url` type, so it stores as `text`
|
|
985
|
+
and validates NOTHING: a `javascript:`/`data:` URL type-checks, imports, and binds. It
|
|
986
|
+
names intent and carries the `text` methods, nothing more. When the value gets navigated
|
|
987
|
+
to, check the scheme in the stack. It is INPUT ONLY — there is no `f.url` column.
|
|
988
|
+
⚠ `s.precondition`'s `error` must be a TAGGED value — `c.text("…")`, not a bare string.
|
|
989
|
+
The engine falls back to the generic "Precondition failed." whenever it reads an empty or
|
|
990
|
+
non-scalar message, and a bare string lands there, so the client never sees your text. The
|
|
991
|
+
`error_type` → HTTP status mapping is correct either way; only the message is lost. The bare
|
|
992
|
+
form stays accepted so a pulled workspace round-trips, not as a spelling to choose.
|
|
993
|
+
`error_type` IS how a stack sets a response status: `badrequest`/`inputerror` → 400, `unauthorized` → 401, `accessdenied` → 403, `notfound` → 404, `toomanyrequests` → 429, `standard` → 500.
|
|
994
|
+
Normalizing transforms run on bind too — put `trim`/`lower`/`upper` on the input's `methods`
|
|
995
|
+
so `inp("name")` reads already-normalized; don't reroll `var $x = inp("name")|trim` in the stack.
|
|
996
|
+
|
|
997
|
+
- `f.text` — methods: alphaOk, digitOk, lower, max, min, ok, pattern, startsWith, trim, upper
|
|
998
|
+
- `f.int` — methods: max, min
|
|
999
|
+
- `f.decimal` — methods: max, min
|
|
1000
|
+
- `f.bool`
|
|
1001
|
+
- `f.uuid`
|
|
1002
|
+
- `f.date`
|
|
1003
|
+
- `f.email` — methods: lower, trim
|
|
1004
|
+
- `f.password` — methods: max, min, minAlpha, minDigit, minLowerAlpha, minSymbol, minUpperAlpha, salt
|
|
1005
|
+
- `f.json`
|
|
1006
|
+
- `f.timestamp` (stored `epochms`)
|
|
1007
|
+
- `f.image` (stored `blob_img`)
|
|
1008
|
+
- `f.video` (stored `blob_video`)
|
|
1009
|
+
- `f.audio` (stored `blob_audio`)
|
|
1010
|
+
- `f.attachment` (stored `blob`)
|
|
1011
|
+
- `input.file` — INPUT ONLY (no `f.` form)
|
|
1012
|
+
- `input.dbLink` (stored `<tableGuid>_mvpschema`) — INPUT ONLY (no `f.` form)
|
|
1013
|
+
- `f.geo.point` (stored `geo_point`)
|
|
1014
|
+
- `f.geo.multipoint` (stored `geo_multipoint`)
|
|
1015
|
+
- `f.geo.linestring` (stored `geo_linestring`)
|
|
1016
|
+
- `f.geo.multilinestring` (stored `geo_multilinestring`)
|
|
1017
|
+
- `f.geo.polygon` (stored `geo_polygon`)
|
|
1018
|
+
- `f.geo.multipolygon` (stored `geo_multipolygon`)
|
|
1019
|
+
- `f.enum`
|
|
1020
|
+
- `f.vector` — methods: max, min
|
|
1021
|
+
- `f.object` (stored `obj`)
|
|
1022
|
+
- `f.tableRef` (stored `int`) — methods: max, min
|
|
1023
|
+
|
|
1024
|
+
# Filter catalog
|
|
1025
|
+
|
|
1026
|
+
> Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
|
|
1027
|
+
|
|
1028
|
+
Attach to a value with `withFilters(v, fl.name(...))` — the value `filters[]`
|
|
1029
|
+
pipeline. Filters are passed spread (canonical); the array form
|
|
1030
|
+
`withFilters(v, [fl.a(), fl.b()])` is also accepted. Every filter is typed:
|
|
1031
|
+
the ones below carry named args, and the rest take NO arguments — call them
|
|
1032
|
+
`fl.<name>()`, and passing an argument is a compile error.
|
|
1033
|
+
A typed filter's declared argument list is an EXACT count, not a floor: passing more
|
|
1034
|
+
than it lists THROWS, in the type and at runtime. The extra argument used to ride into
|
|
1035
|
+
the filter's arg list, where the engine either ignores it or fails opaquely on a live
|
|
1036
|
+
endpoint. `filter("name", …)` is the untyped escape for a filter the catalog
|
|
1037
|
+
under-declares. Seven declared filters are exceptions the engine really does take more from, so they
|
|
1038
|
+
stay variadic: `concat` (trailing separator), `index_by` (trailing list flag), `get` (trailing variable
|
|
1039
|
+
map), `array_merge` and `array_merge_recursive` (any number of arrays), `jwe_encode` and
|
|
1040
|
+
`jwe_decode`. To send more than a zero-argument filter's `()` accepts, use the same
|
|
1041
|
+
`filter("name", …)` escape. A filter with NO declared list takes nothing and is emitted
|
|
1042
|
+
`()` — except the variadic few listed under the catalog, which take arguments the
|
|
1043
|
+
catalog never declared and so are not arity-checked at all.
|
|
1044
|
+
A typed filter also accepts one object of NAMED arguments (`fl.add({ value: 1 })`); a key
|
|
1045
|
+
the filter does not declare throws rather than being dropped.
|
|
1046
|
+
A bare JS **scalar** — string, number or boolean — is accepted in ANY `fl.*` argument and
|
|
1047
|
+
wrapped as the constant you would have written by hand (`fl.get("a.b", 0)` encodes
|
|
1048
|
+
identically to `fl.get(c.text("a.b"), c.int(0))`). An object or array must still be built
|
|
1049
|
+
with `c.obj`/`c.array`. The arg types below name each argument's ENGINE type, not the JS
|
|
1050
|
+
type you may pass.
|
|
1051
|
+
Read-modify-write a column from its current value with the pipeline: to increment
|
|
1052
|
+
a counter you MUST `db.get` the row first, then pipe its bound value —
|
|
1053
|
+
`col("clicks")` does NOT resolve to the stored value inside a `db.edit` `row`
|
|
1054
|
+
(it is `null`, so `fl.add(1)` computes `null + 1` and the engine aborts):
|
|
1055
|
+
`s.db.get({ table, fieldValue, as: "current" })` then
|
|
1056
|
+
`s.db.edit({ table, fieldValue, row: { clicks: withFilters(ref("current.clicks"), fl.add(c.int(1))) } })`.
|
|
1057
|
+
⚠ This read-modify-write is NOT atomic — two concurrent writers can both read the
|
|
1058
|
+
same value and one increment is lost. There is no dedicated atomic-increment
|
|
1059
|
+
statement, and one CANNOT be synthesized in the SDK (it would compile to this same
|
|
1060
|
+
`get` + `edit` pair). For a **concurrency-safe** counter, do the arithmetic in the
|
|
1061
|
+
database with a single `s.db.direct_query` UPDATE (`SET clicks = clicks + 1 WHERE …`),
|
|
1062
|
+
which the DB applies atomically. Reserve the pipeline form for low-contention counters
|
|
1063
|
+
where a rare lost update is acceptable.
|
|
1064
|
+
⚠ `direct_query` needs the table's PHYSICAL Postgres name, which the typed surface
|
|
1065
|
+
does NOT expose: the engine derives a physical name from workspace + table ids (of the
|
|
1066
|
+
form `x<workspace_id>_<table_id>`, e.g. `x6_203970`), ids assigned at import — not knowable
|
|
1067
|
+
from a `table()` def (identity is a name + guid, not the numeric id), and `sql_name`
|
|
1068
|
+
persists empty. So the safe counter drops out of the typed surface: hardcode
|
|
1069
|
+
the physical name after inspecting the deployed table. A typed atomic path needs an
|
|
1070
|
+
engine change.
|
|
1071
|
+
|
|
1072
|
+
- `fl.add(value: decimal): decimal`
|
|
1073
|
+
- `fl.append(value: <T>, path: text): <T>[]`
|
|
1074
|
+
- `fl.array_diff(value: <T>[]): <T>[]`
|
|
1075
|
+
- `fl.array_diff_assoc(value: <T>[]): <T>[]`
|
|
1076
|
+
- `fl.array_fill(start: int, count: int): any[]`
|
|
1077
|
+
- `fl.array_fill_keys(keys: any[]): any[]`
|
|
1078
|
+
- `fl.array_intersect(value: <T>[]): <T>[]`
|
|
1079
|
+
- `fl.array_intersect_assoc(value: <T>[]): <T>[]`
|
|
1080
|
+
- `fl.array_merge(value: <T>[]): <T>[]`
|
|
1081
|
+
- `fl.array_merge_recursive(value: <T>[]): <T>[]`
|
|
1082
|
+
- `fl.array_push(value: <T>): <T>[]`
|
|
1083
|
+
- `fl.array_remove(value: <T>, path: text, strict?: bool): <T>[]`
|
|
1084
|
+
- `fl.array_slice(offset: int, length?: int): <T>[]`
|
|
1085
|
+
- `fl.array_unshift(value: <T>): <T>[]`
|
|
1086
|
+
- `fl.base_convert(from_base: any, to_base: any): text`
|
|
1087
|
+
- `fl.bitwise_and(value: int): int`
|
|
1088
|
+
- `fl.bitwise_or(value: int): int`
|
|
1089
|
+
- `fl.bitwise_xor(value: int): int`
|
|
1090
|
+
- `fl.concat(value: any): text`
|
|
1091
|
+
- `fl.contains(search: text): bool` — piped value is the subject text; the arg is the substring searched for
|
|
1092
|
+
- `fl.convert_encoding(to: text, from: text): text`
|
|
1093
|
+
- `fl.create_object(values: <T>[]): any`
|
|
1094
|
+
- `fl.crypto_jwe_decode(check_claims: json, key: json, key_algorithm: enum, content_algorithm: enum, timeDrift?: int): json`
|
|
1095
|
+
- `fl.crypto_jwe_encode(headers: json, key: json, key_algorithm: enum, content_algorithm: enum, ttl?: int): text`
|
|
1096
|
+
- `fl.crypto_jws_decode(check_claims: json, key: json, algorithm: enum, timeDrift?: int): json`
|
|
1097
|
+
- `fl.crypto_jws_encode(headers: json, key: json, algorithm: enum, ttl?: int): text`
|
|
1098
|
+
- `fl.csv_create(rows: text[], separator: text, enclosure: text, escape: text): text` — the header-writing counterpart to `csv_encode`: the PIPED value is the list of column names (written as the header line) and `rows` carries the data rows
|
|
1099
|
+
- `fl.csv_decode(separator: text, enclosure: text, escape: text): any`
|
|
1100
|
+
- `fl.csv_encode(separator: text, enclosure: text, escape: text): text` — writes NO header — values only, each row in THAT row's key order with no normalization across rows, so rows whose keys differ in order or count silently misalign columns. Nested cells are JSON-encoded and `false` writes empty. A piped array of SCALARS is treated as one row. Use `csv_create` for a header
|
|
1101
|
+
- `fl.csv_parse(separator: text, enclosure: text, escape: text): any`
|
|
1102
|
+
- `fl.decrypt(algorithm: enum, key: text, iv: text): text`
|
|
1103
|
+
- `fl.detect_encoding(encodings?: text): text`
|
|
1104
|
+
- `fl.div(value: decimal): decimal`
|
|
1105
|
+
- `fl.encrypt(algorithm: enum, key: text, iv: text): text`
|
|
1106
|
+
- `fl.ends_with(search: text): bool` — piped value is the subject text; the arg is the substring searched for
|
|
1107
|
+
- `fl.epochms_add_ms(milliseconds: int): epochms`
|
|
1108
|
+
- `fl.epochms_add_secs(seconds: int): epochms`
|
|
1109
|
+
- `fl.epochms_date(format: text, timezone?: text): text`
|
|
1110
|
+
- `fl.epochms_from_format(format: text, timezone?: text): text`
|
|
1111
|
+
- `fl.epochms_transform(format: text, timezone?: text): text` — applies a relative shift (e.g. "+1 day") to the timestamp
|
|
1112
|
+
- `fl.eq(value: <T>): bool`
|
|
1113
|
+
- `fl.every(code: text, timeout?: int): any[]` — `code` is a JS body run per element (true for all?), over `$this`/`$index`/`$parent`
|
|
1114
|
+
- `fl.filter(code: text, timeout?: int): any[]` — `code` is a JS body run per element (keep it? true/false), over `$this`/`$index`/`$parent`
|
|
1115
|
+
- `fl.filter_empty(path?: text): <T>[]` — keeps entries that are not empty ("", null, 0, "0", false, [], {})
|
|
1116
|
+
- `fl.filter_empty_array(path?: text): <T>[]`
|
|
1117
|
+
- `fl.filter_empty_object(path?: text): <T>[]`
|
|
1118
|
+
- `fl.filter_empty_text(path?: text): <T>[]`
|
|
1119
|
+
- `fl.filter_false(path?: text): <T>[]`
|
|
1120
|
+
- `fl.filter_null(path?: text): <T>[]`
|
|
1121
|
+
- `fl.filter_zero(path?: text): <T>[]`
|
|
1122
|
+
- `fl.find(code: text, timeout?: int): any[]` — `code` is a JS body run per element; returns the first element it accepts
|
|
1123
|
+
- `fl.findIndex(code: text, timeout?: int): any[]` — `code` is a JS body run per element; returns the first matching index
|
|
1124
|
+
- `fl.first_notempty(value: any): any` — first value that is not empty ("", null, 0, "0", false, [], {})
|
|
1125
|
+
- `fl.first_notnull(value: any): any`
|
|
1126
|
+
- `fl.fsort(path?: text, type?: "text"|"itext"|"natural"|"inatural"|"number", asc?: bool): <T>[]` — `type` is the comparator, and ONLY "number" compares numerically — "text"/"itext" are strcmp/strcasecmp, "natural"/"inatural" are the human-readable "a2 < a10" orderings. Default "itext". Anything else silently sorts as text, so a numeric sort MUST spell "number"; the path arg drills into each element
|
|
1127
|
+
- `fl.get(path: text, default?: json): any`
|
|
1128
|
+
- `fl.gt(value: <T>): bool`
|
|
1129
|
+
- `fl.gte(value: <T>): bool`
|
|
1130
|
+
- `fl.has(path: text): bool`
|
|
1131
|
+
- `fl.hmac_md5(key: text, raw?: bool): text`
|
|
1132
|
+
- `fl.hmac_sha1(key: text, raw?: bool): text`
|
|
1133
|
+
- `fl.hmac_sha256(key: text, raw?: bool): text`
|
|
1134
|
+
- `fl.hmac_sha384(key: text, raw?: bool): text`
|
|
1135
|
+
- `fl.hmac_sha512(key: text, raw?: bool): text`
|
|
1136
|
+
- `fl.icontains(search: text): bool` — case-insensitive; piped value is the subject, the arg is the substring
|
|
1137
|
+
- `fl.iends_with(search: text): bool` — case-insensitive; piped value is the subject, the arg is the substring
|
|
1138
|
+
- `fl.in(search: <T>): bool`
|
|
1139
|
+
- `fl.index_by(path: text): { [key: string]: <T>[] }` — a GROUP-BY: every value is an ARRAY of the items sharing that key, even when only one does, so a lookup reads `idx[key][0]`. Items whose path is missing or non-scalar are dropped
|
|
1140
|
+
- `fl.istarts_with(search: text): bool` — case-insensitive; piped value is the subject, the arg is the substring
|
|
1141
|
+
- `fl.join(separator: text): text`
|
|
1142
|
+
- `fl.jwe_decode(arg1: any)`
|
|
1143
|
+
- `fl.jwe_encode(arg1: any)`
|
|
1144
|
+
- `fl.lambda(code: text, timeout?: int): any` — runs a JS body once over the piped value, which it binds as `$this` (NOT `$parent`)
|
|
1145
|
+
- `fl.log(base: <T>): decimal`
|
|
1146
|
+
- `fl.lt(value: <T>): bool`
|
|
1147
|
+
- `fl.lte(value: <T>): bool`
|
|
1148
|
+
- `fl.ltrim(mask?: text): text`
|
|
1149
|
+
- `fl.map(code: text, timeout?: int): any[]` — `code` is a JS body run per element, over `$this`/`$index`/`$parent` — build it with `lam.fn`
|
|
1150
|
+
- `fl.md5(raw?: bool): text`
|
|
1151
|
+
- `fl.mod(value: int): int`
|
|
1152
|
+
- `fl.mul(value: decimal): decimal`
|
|
1153
|
+
- `fl.ne(value: <T>): bool`
|
|
1154
|
+
- `fl.num_max(value: any): decimal`
|
|
1155
|
+
- `fl.num_min(value: any): decimal`
|
|
1156
|
+
- `fl.number_format(decimals: int, decimal_separator: text, thousands_separator: text): string`
|
|
1157
|
+
- `fl.pick(keys: text): <T>`
|
|
1158
|
+
- `fl.pow(exp: <T>): decimal`
|
|
1159
|
+
- `fl.prepend(value: <T>, path: text): <T>[]`
|
|
1160
|
+
- `fl.range(start: int, stop: int): int[]`
|
|
1161
|
+
- `fl.reduce(initial_value: int, code: text, timeout?: int): any[]` — `code` is a JS body run per element; the ACCUMULATOR is `$result` (there is no `$acc`) and `initial_value` is REQUIRED — omitting it would slot the code as the initial value
|
|
1162
|
+
- `fl.regex_match(subject: text): text[]`
|
|
1163
|
+
- `fl.regex_match_all(subject: text): text[]`
|
|
1164
|
+
- `fl.regex_quote(delimiter?: text): text`
|
|
1165
|
+
- `fl.regex_replace(replacement: text, subject: text): text`
|
|
1166
|
+
- `fl.regex_test(subject: text): bool`
|
|
1167
|
+
- `fl.round(precision?: int): decimal`
|
|
1168
|
+
- `fl.rtrim(mask?: text): text`
|
|
1169
|
+
- `fl.secureid_decode(salt: text): int`
|
|
1170
|
+
- `fl.secureid_encode(salt: text): text`
|
|
1171
|
+
- `fl.set(path: text, value: any): any`
|
|
1172
|
+
- `fl.set_conditional(path: text, value: any, conditional: any): any`
|
|
1173
|
+
- `fl.set_ifnotempty(path: text, value: any): any`
|
|
1174
|
+
- `fl.set_ifnotnull(path: text, value: any): any`
|
|
1175
|
+
- `fl.sha1(raw?: bool): text`
|
|
1176
|
+
- `fl.sha256(raw?: bool): text`
|
|
1177
|
+
- `fl.sha384(raw?: bool): text`
|
|
1178
|
+
- `fl.sha512(raw?: bool): text`
|
|
1179
|
+
- `fl.some(code: text, timeout?: int): any[]` — `code` is a JS body run per element (true for any?), over `$this`/`$index`/`$parent`
|
|
1180
|
+
- `fl.split(separator: text): text[]`
|
|
1181
|
+
- `fl.starts_with(search: text): bool` — piped value is the subject text; the arg is the substring searched for
|
|
1182
|
+
- `fl.string_replace(search: text, replacement: text): text`
|
|
1183
|
+
- `fl.strip_tags(exclude?: text): text`
|
|
1184
|
+
- `fl.stripos(search: text): int`
|
|
1185
|
+
- `fl.strpos(search: text): int`
|
|
1186
|
+
- `fl.sub(value: decimal): decimal`
|
|
1187
|
+
- `fl.substr(start: int, length: int): text`
|
|
1188
|
+
- `fl.to_epoch_day(timezone?: text): int`
|
|
1189
|
+
- `fl.to_epoch_hour(timezone?: text): int`
|
|
1190
|
+
- `fl.to_epoch_minute(timezone?: text): int`
|
|
1191
|
+
- `fl.to_epoch_ms(timezone?: text): int`
|
|
1192
|
+
- `fl.to_epoch_sec(timezone?: text): int`
|
|
1193
|
+
- `fl.to_epochms(timezone?: text): epochms`
|
|
1194
|
+
- `fl.transform(expression: text): any` — `expression` is Xano Expression Engine source, NOT a JS body — no `return`, and the piped value is `$0` (or `$$`), NOT `$this` (which is null here). `$var`/`$input`/`$env`/`$auth` resolve and filters pipe inside it: `$0 * 2`, `$0|sort|join:","`. Parenthesize a pipe inside an object literal — `{ s: ($0|sort|join:",") }` — or its comma is read as the key separator and later keys vanish silently. For JavaScript use `lambda`
|
|
1195
|
+
- `fl.trim(mask?: text): text`
|
|
1196
|
+
- `fl.unique(path?: text): <T>[]`
|
|
1197
|
+
- `fl.unpick(keys: text): <T>` — returns the object without the named keys (inverse of a pick)
|
|
1198
|
+
- `fl.unset(path: text): any`
|
|
1199
|
+
- `fl.url_addarg(key: text, value: text, encoding_rfc3986?: bool): text`
|
|
1200
|
+
- `fl.url_delarg(key: text): text`
|
|
1201
|
+
- `fl.url_getarg(key: text, default?: text): text`
|
|
1202
|
+
- `fl.url_hasarg(key: text): text`
|
|
1203
|
+
|
|
1204
|
+
Zero-argument filters — call as `fl.<name>()`; an argument is a compile error: abs, acos, acosh, addslashes, array_entries, array_keys, array_pop, array_shift, array_shuffle, array_values, asin, asinh, atan, atanh, avg, base64_decode, base64_decode_urlsafe, base64_encode, base64_encode_urlsafe, bin2hex, bindec, bitwise_not, capitalize, ceil, cos, count, create_object_from_entries, decbin, dechex, decoct, deg2rad, empty, escape, even, exp, first, flatten, floor, from_utf8, hex2bin, hexdec, is_array, is_bool, is_decimal, is_int, is_object, is_text, is_uuid, json_decode, json_encode, last, list_encodings, ln, log10, lower, max, min, not, null, octdec, odd, product, querystring_parse, rad2deg, reverse, safe_array, sin, sql_alias, sql_esc, sqrt, strip_accents, strlen, sum, tan, text_escape, text_unescape, to_bool, to_decimal, to_expr, to_int, to_text, to_utf8, uid, upper, url_decode, url_decode_rfc3986, url_encode, url_encode_rfc3986, url_parse, uuid4, xml_decode, yaml_decode, yaml_encode.
|
|
1205
|
+
|
|
1206
|
+
Variadic filters — they take arguments, but no declared list, so the count is not checked: sort, sprintf.
|
|
1207
|
+
|
|
1208
|
+
# Lambda bodies (JavaScript)
|
|
1209
|
+
|
|
1210
|
+
> 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, each surface binds a different set of identifiers, and the hazards are not guessable.
|
|
1211
|
+
|
|
1212
|
+
**A lambda is an escape hatch, not a default.** The body runs outside the request's own
|
|
1213
|
+
runtime, and a workspace has a BOUNDED pool of lambda workers every lambda in it shares
|
|
1214
|
+
— so a call both crosses a process boundary and draws on a workspace-wide resource.
|
|
1215
|
+
Reach for one only when the typed surface cannot express the work: if a native filter,
|
|
1216
|
+
an `expr(...)`/`obj(...)` expression, or a plain statement can, use that. The crossing
|
|
1217
|
+
is per CALL, not per element — an iterating filter sends the body ONCE and loops on the
|
|
1218
|
+
other side, so one body over a whole list beats one called from inside a stack loop.
|
|
1219
|
+
|
|
1220
|
+
The lambda statement (`s.lambda({ as, code, timeout? })`) and eight filters run a
|
|
1221
|
+
JavaScript body. **Write the body as a FUNCTION, not a `c.text` string** — the
|
|
1222
|
+
bindings are its parameters, so the editor supplies them and a wrong name is a
|
|
1223
|
+
compile error instead of a wrong value at runtime. Write it inline and the surface
|
|
1224
|
+
is implied by where it sits; nothing names one:
|
|
1225
|
+
|
|
1226
|
+
```ts
|
|
1227
|
+
fl.map(({ $this }) => $this * 2) // map's bindings, typed from the position
|
|
1228
|
+
fl.reduce({ initial_value: 0, code: ({ $result, $this }) => $result + $this })
|
|
1229
|
+
s.lambda({ as: "total", code: ({ $var }) => $var.subtotal * 1.2 }) // ambient only — $this is a compile error
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
The parameters are a fiction — only the BODY is sent, and the engine injects the
|
|
1233
|
+
bindings as free identifiers — so DESTRUCTURE them. `(b) => b.$this` emits
|
|
1234
|
+
`return b.$this`, and `b` is undefined at runtime (the SDK refuses it).
|
|
1235
|
+
|
|
1236
|
+
⚠ An inline `code:` arrow receives BINDINGS ONLY. `capture` is an option of
|
|
1237
|
+
`lam.fn`, not a field of `s.lambda` or of a filter — to pass data in, move the
|
|
1238
|
+
body into `lam.fn(fn, { capture })` (below). Writing `capture:` beside `code:`
|
|
1239
|
+
is a type error, and the fix is to relocate the body, not to drop the field.
|
|
1240
|
+
|
|
1241
|
+
For a body built away from its call site:
|
|
1242
|
+
|
|
1243
|
+
- `lam.fn(({ $result, $this }) => $result + $this, { surface?, capture? })` — name a `surface` to check it here, or omit it and the call site checks it.
|
|
1244
|
+
- `lam.raw("return 1", { surface })` — text, same validation.
|
|
1245
|
+
- `lam.file("./lambdas/total.ts")` — a default-exported function in its own type-checked module, read as text at build time. The deterministic option under a bundler, where a function's source is whatever the bundler emitted. NODE ONLY, and it is the `lam` import that changes: `import { lam } from "@xanots/sdk/node"`. The isomorphic `lam` has no `file` (no filesystem in a browser bundle); its `fn` and `raw` are the same functions.
|
|
1246
|
+
|
|
1247
|
+
Nothing from the enclosing scope crosses implicitly. The body is sent as TEXT and runs
|
|
1248
|
+
in a different process, so a closed-over `const rate` is undefined there, and the body
|
|
1249
|
+
throwing on it returns the diagnostic text with HTTP 200 — a wrong VALUE, not an error.
|
|
1250
|
+
Put what the body needs in `capture`; it arrives as the SECOND parameter and is emitted
|
|
1251
|
+
ahead of the body as a `const` prelude:
|
|
1252
|
+
|
|
1253
|
+
```ts
|
|
1254
|
+
lam.fn(({ $this }, { capturedRate }) => $this * capturedRate, { surface: "map", capture: { capturedRate: rate } })
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
⚠ A capture key must NOT share its name with a module-scope binding. An inline body is
|
|
1258
|
+
recovered with `toString()`, and a `.ts` loader renames one of two same-named bindings —
|
|
1259
|
+
so the body reads `rate2` while the prelude declares `rate`, and `rate2` is undefined at
|
|
1260
|
+
runtime (the body throws and the engine returns that text in the value slot with HTTP
|
|
1261
|
+
200 — a wrong value, not an error). Build time refuses it. The key does not have to keep
|
|
1262
|
+
the name of what it carries: `capture: { capturedRate: rate }` above is the safe form.
|
|
1263
|
+
|
|
1264
|
+
Capture JSON data only — string, number, boolean, null, object, array. A function,
|
|
1265
|
+
`undefined`, `symbol` or `bigint` has no JSON form that survives, and a `Date`/`Map`/
|
|
1266
|
+
`Set`/`RegExp` has one that LIES (a `Date` arrives as a string, the rest as `{}`), so
|
|
1267
|
+
all of them are refused at build time — the class ones at any depth. Capture the plain
|
|
1268
|
+
form and rebuild in the body (`d.getTime()` → `new Date(d)`). The captured type flows into
|
|
1269
|
+
that second parameter (no explicit type arguments), and an object may be declared as an
|
|
1270
|
+
`interface` or a `type` alias alike.
|
|
1271
|
+
|
|
1272
|
+
A body is a FUNCTION BODY: it must `return` its value. Bindings by surface — an
|
|
1273
|
+
identifier outside its surface's set is undefined at runtime, and the SDK refuses
|
|
1274
|
+
it at build time whichever spelling you use:
|
|
1275
|
+
|
|
1276
|
+
- every surface: `$env` · `$input` · `$var` · `$auth` (+ the `console` / `crypto` globals)
|
|
1277
|
+
- `fl.lambda`: + `$this`
|
|
1278
|
+
- `fl.map` · `fl.filter` · `fl.some` · `fl.every` · `fl.find` · `fl.findIndex`: + `$this` · `$index` · `$parent`
|
|
1279
|
+
- `fl.reduce`: + `$this` · `$index` · `$parent` · `$result`
|
|
1280
|
+
- `s.lambda`: ambient only — no `$this`, no `$parent`, no `$result`.
|
|
1281
|
+
|
|
1282
|
+
`$result` is `reduce`'s ACCUMULATOR (there is no `$acc`). `$this` is the element in
|
|
1283
|
+
an iterating filter and the piped value in `fl.lambda`; `$parent` is the whole array
|
|
1284
|
+
and exists only on the iterating filters. A stack variable is reached as
|
|
1285
|
+
`$var.name` — it is NOT also injected as a bare `$name`.
|
|
1286
|
+
|
|
1287
|
+
Four hazards and the dependency route, all live-verified:
|
|
1288
|
+
|
|
1289
|
+
- ⚠ A body that THROWS does not fail the request: the engine returns its diagnostic
|
|
1290
|
+
TEXT as the value with HTTP 200, so the failure reads as bad data. Validate before
|
|
1291
|
+
consuming a lambda result numerically, and prefer a `lam.*` body, which cannot fail
|
|
1292
|
+
this way for a binding reason.
|
|
1293
|
+
- ⚠ `timeout` bounds a body that AWAITS, not one that spins. It is COOPERATIVE — only
|
|
1294
|
+
observed at an `await` — so synchronous work runs to completion however long it takes:
|
|
1295
|
+
a 1s `timeout` over a body that busy-loops for 3s lets it run all 3 and return
|
|
1296
|
+
normally. Treat it as a bound on WAITING (a slow `fetch`), not a kill switch on
|
|
1297
|
+
compute — if a loop could run away, bound it yourself inside the body.
|
|
1298
|
+
- ⚠ A top-level `import`/`export` is a syntax error — the body is a function body, not
|
|
1299
|
+
a module. Reach a dependency through the PRELOADED globals below, which need no
|
|
1300
|
+
specifier. A dynamic `import("…")` or `require("…")` with a LITERAL specifier is not
|
|
1301
|
+
portable: on an instance that bundles the body before running it, every literal
|
|
1302
|
+
specifier is resolved ahead of time against a filesystem where none of them exist, so
|
|
1303
|
+
`await import("node:crypto")` comes back as the TEXT `Could not resolve "node:crypto"`
|
|
1304
|
+
with HTTP 200. Other instances resolve it at run time and it works — so it is
|
|
1305
|
+
instance-dependent, and only the globals are not.
|
|
1306
|
+
- Preloaded globals, live-probed — no specifier, so these work everywhere:
|
|
1307
|
+
`_` · `aws4` · `axios` · `cryptojs` · `DateTime` · `ethers` · `fastXmlParser` ·
|
|
1308
|
+
`jose` · `luxon` · `mailparser` · `math` · `moment` · `nodemailer` · `socks` ·
|
|
1309
|
+
`uuid` · `utils`
|
|
1310
|
+
…plus `fetch`, `Buffer`, `TextEncoder`/`TextDecoder`, and the `crypto` above
|
|
1311
|
+
(`randomUUID`, `createHmac`, `subtle` all present). `Object.keys(globalThis)`
|
|
1312
|
+
inside a body lists whatever else a given instance carries.
|
|
1313
|
+
- ⚠ `console` output goes to the request LOG, not stdout. `log` · `error` · `warn` ·
|
|
1314
|
+
`info` · `debug` · `trace` all route there; the body's `console` is a purpose-built
|
|
1315
|
+
object, so anything outside that set is undefined and CALLING it throws — which,
|
|
1316
|
+
per the first hazard, replaces the return value with the error text at HTTP 200.
|
|
1317
|
+
|
|
1318
|
+
TypeScript annotations survive in the body, and top-level `await` works.
|
|
1319
|
+
|
|
1320
|
+
# Legacy paradigms and retired statements
|
|
1321
|
+
|
|
1322
|
+
> 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. None of the retired names are authorable for new code.
|
|
1323
|
+
|
|
1324
|
+
Older paradigms this SDK still supports and still emits when it decodes an existing
|
|
1325
|
+
workspace. **Do not author these.** They are listed by name only so you recognize them
|
|
1326
|
+
in pulled code rather than "fixing" them; each line names what to use instead.
|
|
1327
|
+
|
|
1328
|
+
Names overlap across the split deliberately — the engine reused words like
|
|
1329
|
+
"realtime" and "channel" for both generations. A name matching is NOT evidence that
|
|
1330
|
+
two things are the same object; check which list it came from.
|
|
1331
|
+
|
|
1332
|
+
- `c.expressionLegacy` — the older `const:expr` expression form, emitted by codegen for workspaces that still hold one — author `c.expression` instead.
|
|
1333
|
+
- `realtimeTrigger()` — the SUPERSEDED realtime trigger, against the workspace-global realtime layer — a different object from the current `channel`, despite the similar name. For a join hook use `realtimeChannelTrigger({ actions: { join: true } })`; for message handling use a `realtimeMessage()` handler, which is the current equivalent of its `message` action (a message is an authored unit now, not a trigger action).
|
|
1334
|
+
- `s.api.realtime_event` — publishes to the SUPERSEDED workspace-global realtime layer, NOT to a `realtimeChannel()` — its `channel` is a string against that layer, so pointing it at a current-layer channel path publishes into the void. Use `s.realtime.publish` instead: it names the owning `realtimeServer()`, so it addresses a real `realtimeChannel()`.
|
|
1335
|
+
|
|
1336
|
+
Retired statement VERSIONS — no `s.` surface exists. Pulled code shows them as
|
|
1337
|
+
`raw({ name: "…" })` and they keep running as stored, so leave them; author the
|
|
1338
|
+
replacement only for NEW code. Never swap one for the other — each version broke the last.
|
|
1339
|
+
|
|
1340
|
+
- `mvp:crypto_jwe_decode` → `mvp:crypto_jwe_decode2`
|
|
1341
|
+
- `mvp:crypto_jwe_encode` → `mvp:crypto_jwe_encode3`
|
|
1342
|
+
- `mvp:crypto_jwe_encode2` → `mvp:crypto_jwe_encode3`
|
|
1343
|
+
- `mvp:crypto_jws_decode` → `mvp:crypto_jws_decode2`
|
|
1344
|
+
- `mvp:crypto_jws_encode` → `mvp:crypto_jws_encode2`
|
|
1345
|
+
- `mvp:connect_ncscale_send_log` — retired, no replacement
|
|
1346
|
+
|
|
1347
|
+
Statements the engine writes but will NOT import back — no `s.` surface exists, and
|
|
1348
|
+
unlike the retired versions above these must be FIXED, not left alone. Pulled code shows
|
|
1349
|
+
them as `raw({ name: "…" })`; `export()` refuses any bundle that still contains one.
|
|
1350
|
+
|
|
1351
|
+
- `mvp:placeholder` — an unconfigured statement slot the engine writes in place of a statement it could not resolve, so an export stays well-formed. There is no statement class behind it: importing a workspace that contains one fails outright with "Missing statement: mvp:placeholder". Replace it with the statement it stands in for before deploying — there is no destination where it runs.
|
|
1352
|
+
|
|
1353
|
+
## Reading a pulled tree
|
|
1354
|
+
|
|
1355
|
+
`codegen` emits objects as FACTORY calls (`table({...})`, `query({...})`, …) — the form
|
|
1356
|
+
you author by hand — so inference survives the round trip.
|
|
1357
|
+
Three shapes read differently and must not be "fixed": a trigger no factory expresses
|
|
1358
|
+
(the realtime types, or a non-default `history`) stays `{...} satisfies TriggerDef`; a
|
|
1359
|
+
statement the catalog cannot model round-trips verbatim through `raw()`
|
|
1360
|
+
(`@xanots/sdk/codegen`); and an object ALREADY EMPTY upstream decodes to a def with
|
|
1361
|
+
no `stack`, reported as `empty-source` — faithful, not a decode failure. ⚠ Workspace env
|
|
1362
|
+
var VALUES ride inline in `xano/workspace.ts`, so treat a pulled tree as secret-bearing.
|
|
1363
|
+
|
|
1364
|
+
# Statement catalog
|
|
1365
|
+
|
|
1366
|
+
> 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.
|
|
1367
|
+
|
|
1368
|
+
### (top-level)
|
|
1369
|
+
|
|
1370
|
+
- `s.await({ as?: string; ids?: value; timeout?: value })`
|
|
1371
|
+
- `s.foreach_break` [special]
|
|
1372
|
+
- `s.comment` [special]
|
|
1373
|
+
- `s.conditional` [special]
|
|
1374
|
+
- `s.foreach_continue` [special]
|
|
1375
|
+
- `s.for` [special]
|
|
1376
|
+
- `s.foreach_remove` [special]
|
|
1377
|
+
- `s.foreach` [special]
|
|
1378
|
+
- `s.group` [special]
|
|
1379
|
+
- `s.lambda({ as?: string; code?: value; timeout?: value })`
|
|
1380
|
+
- `s.precondition({ expr?: comparison; error_type?: "standard" | "notfound" | "toomanyrequests" | "accessdenied" | "unauthorized" | "badrequest" | "inputerror"; error?: value; payload?: value })`
|
|
1381
|
+
- `s.return` [special]
|
|
1382
|
+
- `s.switch` [special]
|
|
1383
|
+
- `s.throw({ name?: string; value: value })`
|
|
1384
|
+
- `s.try_catch` [special]
|
|
1385
|
+
- `s.update_var` [special]
|
|
1386
|
+
- `s.set_var` [special]
|
|
1387
|
+
- `s.while` [special]
|
|
1388
|
+
|
|
1389
|
+
### action
|
|
1390
|
+
|
|
1391
|
+
- `s.action.call` [special]
|
|
1392
|
+
- `s.action.package.call` [special]
|
|
1393
|
+
|
|
1394
|
+
### addon
|
|
1395
|
+
|
|
1396
|
+
- `s.addon.call` [special]
|
|
1397
|
+
|
|
1398
|
+
### ai
|
|
1399
|
+
|
|
1400
|
+
- `s.ai.agent.run` [special]
|
|
1401
|
+
- `s.ai.external.mcp.server_details({ as?: string; url?: value; bearer_token?: value; connection_type?: "sse" | "stream" })`
|
|
1402
|
+
- `s.ai.external.mcp.tool.list({ as?: string; url?: value; bearer_token?: value; connection_type?: "sse" | "stream" })`
|
|
1403
|
+
- `s.ai.external.mcp.tool.run({ as?: string; url?: value; bearer_token?: value; connection_type?: "sse" | "stream"; tool?: value; args?: value })`
|
|
1404
|
+
|
|
1405
|
+
### api
|
|
1406
|
+
|
|
1407
|
+
- `s.api.call` [special]
|
|
1408
|
+
- `s.api.request` [special, output]
|
|
1409
|
+
- `s.api.stream({ value: value })`
|
|
1410
|
+
|
|
1411
|
+
### array
|
|
1412
|
+
|
|
1413
|
+
- `s.array.difference({ as?: string; expr?: value; value?: value; by?: value })`
|
|
1414
|
+
- `s.array.every({ expr?: value; as?: string; if?: comparison })` [output] → as: boolean
|
|
1415
|
+
- `s.array.filter_count({ expr?: value; as?: string; if?: comparison })`
|
|
1416
|
+
- `s.array.filter({ expr?: value; as?: string; if?: comparison })`
|
|
1417
|
+
- `s.array.find_index({ expr?: value; as?: string; if?: comparison })`
|
|
1418
|
+
- `s.array.find({ expr?: value; as?: string; if?: comparison })` [output]
|
|
1419
|
+
- `s.array.group_by({ as?: string; expr?: value; by?: value })`
|
|
1420
|
+
- `s.array.has({ expr?: value; as?: string; if?: comparison })`
|
|
1421
|
+
- `s.array.intersection({ as?: string; expr?: value; value?: value; by?: value })`
|
|
1422
|
+
- `s.array.map` [special]
|
|
1423
|
+
- `s.array.merge({ name?: string; value?: value })` [output]
|
|
1424
|
+
- `s.array.partition({ expr?: value; as?: string; if?: comparison })`
|
|
1425
|
+
- `s.array.pop({ name?: string; as?: string })` [output]
|
|
1426
|
+
- `s.array.push({ name?: string; value: value })` [output]
|
|
1427
|
+
- `s.array.shift({ name?: string; as?: string })`
|
|
1428
|
+
- `s.array.union` [special]
|
|
1429
|
+
- `s.array.unshift({ name?: string; value: value })`
|
|
1430
|
+
|
|
1431
|
+
### cloud
|
|
1432
|
+
|
|
1433
|
+
- `s.cloud.algolia.request({ as?: string; application_id: value; api_key: value; url: value; method?: "POST" | "GET" | "DELETE" | "PUT"; payload: value })` [output]
|
|
1434
|
+
- `s.cloud.aws.opensearch.document({ as?: string; auth_type?: "IAM" | "master"; key_id?: value; access_key?: value; region?: value; base_url: value; method?: "GET" | "POST" | "PUT" | "DELETE"; index?: value; doc_id?: value; doc?: value })`
|
|
1435
|
+
- `s.cloud.aws.opensearch.query({ as?: string; auth_type?: "IAM" | "master"; key_id?: value; access_key?: value; region?: value; base_url?: value; index?: value; payload?: value; size?: value; from?: value; included_fields?: value; return_type?: "search" | "count"; expression?: value; sort?: value })`
|
|
1436
|
+
- `s.cloud.aws.opensearch.request({ as?: string; auth_type?: "IAM" | "master"; key_id?: value; access_key?: value; region?: value; method?: "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH"; url?: value; query?: value })`
|
|
1437
|
+
- `s.cloud.aws.s3.delete_file({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value })`
|
|
1438
|
+
- `s.cloud.aws.s3.get_file_info({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value })`
|
|
1439
|
+
- `s.cloud.aws.s3.list_directory({ as?: string; bucket: value; region: value; key: value; secret: value; prefix?: value; next_page_token?: value })`
|
|
1440
|
+
- `s.cloud.aws.s3.read_file({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value })`
|
|
1441
|
+
- `s.cloud.aws.s3.sign_url({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value; ttl?: value })`
|
|
1442
|
+
- `s.cloud.aws.s3.upload_file({ as?: string; bucket: value; region: value; key: value; secret: value; file_key?: value; file: value; metadata?: value; object_lock_mode?: "compliance" | "governance"; object_lock_retain_until?: value })`
|
|
1443
|
+
- `s.cloud.azure.storage.delete_file({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value })`
|
|
1444
|
+
- `s.cloud.azure.storage.get_file_info({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value })`
|
|
1445
|
+
- `s.cloud.azure.storage.list_directory({ as?: string; account_name: value; account_key: value; container_name: value; path?: value })`
|
|
1446
|
+
- `s.cloud.azure.storage.read_file({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value })`
|
|
1447
|
+
- `s.cloud.azure.storage.sign_url({ as?: string; account_name: value; account_key: value; container_name: value; path: value; ttl?: value })`
|
|
1448
|
+
- `s.cloud.azure.storage.upload_file({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value; file: value; metadata?: value })`
|
|
1449
|
+
- `s.cloud.elasticsearch.document({ as?: string; auth_type?: "Basic" | "Bearer" | "API Key"; key_id: value; access_key: value; base_url: value; index: value; method?: "GET" | "POST" | "PUT" | "DELETE"; doc_id: value; doc: value })`
|
|
1450
|
+
- `s.cloud.elasticsearch.query({ as?: string; auth_type?: "Basic" | "Bearer" | "API Key"; key_id?: value; access_key?: value; base_url?: value; index?: value; payload?: value; size?: value; from?: value; included_fields?: value; return_type?: "search" | "count"; expression?: value; sort?: value })`
|
|
1451
|
+
- `s.cloud.elasticsearch.request({ as?: string; auth_type?: "Basic" | "Bearer" | "API Key"; key_id?: value; access_key?: value; method?: "POST" | "GET" | "PUT" | "DELETE" | "PATCH"; url?: value; payload?: value })`
|
|
1452
|
+
- `s.cloud.google.storage.delete_file({ as?: string; service_account: value; bucket: value; filePath: value })`
|
|
1453
|
+
- `s.cloud.google.storage.get_file_info({ as?: string; service_account: value; bucket: value; filePath: value })`
|
|
1454
|
+
- `s.cloud.google.storage.list_directory({ as?: string; service_account: value; bucket: value; path: value })`
|
|
1455
|
+
- `s.cloud.google.storage.read_file({ as?: string; service_account: value; bucket: value; filePath: value })`
|
|
1456
|
+
- `s.cloud.google.storage.sign_url({ as?: string; service_account: value; bucket: value; filePath: value; method?: "GET" | "POST"; ttl?: value })`
|
|
1457
|
+
- `s.cloud.google.storage.upload_file({ as?: string; service_account: value; bucket: value; filePath: value; file: value; metadata?: value })`
|
|
1458
|
+
- `s.cloud.job.await` [special]
|
|
1459
|
+
- `s.cloud.job.status` [special]
|
|
1460
|
+
- `s.cloud.job` [special]
|
|
1461
|
+
|
|
1462
|
+
### datadog
|
|
1463
|
+
|
|
1464
|
+
- `s.datadog.log_bulk({ entries: value; connection?: value })`
|
|
1465
|
+
- `s.datadog.log({ message?: value; status?: "debug" | "info" | "notice" | "warn" | "error" | "critical" | "alert" | "emergency"; attributes?: value; service?: value; source?: value; env?: value; hostname?: value; tags?: value; timestamp?: value; connection?: value })`
|
|
1466
|
+
- `s.datadog.metric_bulk({ entries: value; connection?: value })`
|
|
1467
|
+
- `s.datadog.metric({ metric?: value; value: value; type?: "count" | "gauge" | "rate" | "histogram" | "distribution"; tags?: value; service?: value; source?: value; env?: value; hostname?: value; timestamp?: value; connection?: value })`
|
|
1468
|
+
|
|
1469
|
+
### db
|
|
1470
|
+
|
|
1471
|
+
- `s.db.add_or_edit` [special] → as: InferRow<T> (upserts and never misses)
|
|
1472
|
+
- `s.db.add` [special] → as: InferRow<T> (the full inserted row incl. id/created_at)
|
|
1473
|
+
- `s.db.bulk.add` [special]
|
|
1474
|
+
- `s.db.bulk.delete` [special] → as: number (count of deleted rows)
|
|
1475
|
+
- `s.db.bulk.patch` [special] → as: InferRow<T>[]
|
|
1476
|
+
- `s.db.bulk.update` [special]
|
|
1477
|
+
- `s.db.del` [special] → as: null (the engine deletes and returns no value; throws NotFound on a miss)
|
|
1478
|
+
- `s.db.direct_query` [special]
|
|
1479
|
+
- `s.db.edit` [special] → as: InferRow<T> (the full post-mutation row; throws NotFound on a miss)
|
|
1480
|
+
- `s.db.external.mssql.direct_query` [special]
|
|
1481
|
+
- `s.db.external.mysql.direct_query` [special]
|
|
1482
|
+
- `s.db.external.oracle.direct_query` [special]
|
|
1483
|
+
- `s.db.external.postgres.direct_query` [special]
|
|
1484
|
+
- `s.db.external.snowflake.direct_query` [special]
|
|
1485
|
+
- `s.db.get_by_id` [special]
|
|
1486
|
+
- `s.db.get` [special] → as: InferRow<T> | null (binds null on a miss, never throws)
|
|
1487
|
+
- `s.db.has` [special] → as: boolean
|
|
1488
|
+
- `s.db.patch` [special] → as: InferRow<T> (the full post-mutation row; throws NotFound on a miss)
|
|
1489
|
+
- `s.db.query` [special] → as: InferRow<T>[] (a paging envelope when metadata paging is on)
|
|
1490
|
+
- `s.db.schema` [special]
|
|
1491
|
+
- `s.db.set_datasource({ value: value; workspace_id?: value })`
|
|
1492
|
+
- `s.db.transaction` [special]
|
|
1493
|
+
- `s.db.truncate` [special]
|
|
1494
|
+
|
|
1495
|
+
### debug
|
|
1496
|
+
|
|
1497
|
+
Despite the namespace, `s.debug.stop` is a REAL control-flow terminator, not a development aid: it ends the request with an error value (`mvp:die`). It is also exported flat as `die()` — the one flat alias whose name does not mirror its `s.*` path, so there is no `s.die`. Compare `s.throw`, which raises a named error the surrounding `s.try_catch` can catch.
|
|
1498
|
+
|
|
1499
|
+
- `s.debug.log({ value: value })`
|
|
1500
|
+
- `s.debug.stop({ value: value })`
|
|
1501
|
+
|
|
1502
|
+
### expect
|
|
1503
|
+
|
|
1504
|
+
Assertions. **Put them in a `workflowTest({...})` stack** — assert on what a `.call` bound with `as`. That is where they belong and effectively the only place to author them. They are NOT inert elsewhere, which is the part worth knowing: a failure raises and aborts whatever stack it is in, so an `s.expect.*` left in a `query`/`function`/`task` takes the request down with an HTTP 500 carrying the assertion's own message (`to_equal failed - expected value 2 does not equal 1`). Treat one outside a `workflowTest` as a mistake to remove, not as a check that quietly does nothing. Two behaviours to know when writing them: `to_throw` sees only an error carrying a MESSAGE, so a failure whose message is empty — an endpoint answering `ERROR_CODE_ACCESS_DENIED` is the common one — reports "response is ok" as though nothing failed, and `to_be_within` EXCLUDES both bounds — `min < expr < max` — while `s.security.random_number`'s bounds are inclusive.
|
|
1505
|
+
|
|
1506
|
+
- `s.expect.to_be_defined({ expr?: value })`
|
|
1507
|
+
- `s.expect.to_be_empty({ expr?: value })`
|
|
1508
|
+
- `s.expect.to_be_false({ expr?: value })`
|
|
1509
|
+
- `s.expect.to_be_greater_than({ expr?: value; value?: value })`
|
|
1510
|
+
- `s.expect.to_be_in_the_future({ expr?: value })`
|
|
1511
|
+
- `s.expect.to_be_in_the_past({ expr?: value })`
|
|
1512
|
+
- `s.expect.to_be_less_than({ expr?: value; value?: value })`
|
|
1513
|
+
- `s.expect.to_be_null({ expr?: value })`
|
|
1514
|
+
- `s.expect.to_be_true({ expr?: value })`
|
|
1515
|
+
- `s.expect.to_be_within({ expr?: value; min?: value; max?: value })`
|
|
1516
|
+
- `s.expect.to_contain({ expr?: value; value?: value })`
|
|
1517
|
+
- `s.expect.to_end_with({ expr?: value; value?: value })`
|
|
1518
|
+
- `s.expect.to_equal({ expr?: value; value?: value })`
|
|
1519
|
+
- `s.expect.to_match({ expr?: value; value?: value })`
|
|
1520
|
+
- `s.expect.to_not_be_defined({ expr?: value })`
|
|
1521
|
+
- `s.expect.to_not_be_null({ expr?: value })`
|
|
1522
|
+
- `s.expect.to_not_equal({ expr?: value; value?: value })`
|
|
1523
|
+
- `s.expect.to_start_with({ expr?: value; value?: value })`
|
|
1524
|
+
- `s.expect.to_throw` [special]
|
|
1525
|
+
|
|
1526
|
+
### function
|
|
1527
|
+
|
|
1528
|
+
- `s.function.call` [special]
|
|
1529
|
+
- `s.function.run` [special]
|
|
1530
|
+
|
|
1531
|
+
### math
|
|
1532
|
+
|
|
1533
|
+
- `s.math.add({ name?: string; value: value })` [output] → as: number
|
|
1534
|
+
- `s.math.bitwise.and({ name?: string; value: value })` [output] → as: number
|
|
1535
|
+
- `s.math.bitwise.or({ name?: string; value: value })` [output] → as: number
|
|
1536
|
+
- `s.math.bitwise.xor({ name?: string; value: value })` [output] → as: number
|
|
1537
|
+
- `s.math.div({ name?: string; value: value })`
|
|
1538
|
+
- `s.math.mod({ name?: string; value: value })`
|
|
1539
|
+
- `s.math.mul({ name?: string; value: value })`
|
|
1540
|
+
- `s.math.sub({ name?: string; value: value })`
|
|
1541
|
+
|
|
1542
|
+
### microservice
|
|
1543
|
+
|
|
1544
|
+
- `s.microservice.request` [special]
|
|
1545
|
+
|
|
1546
|
+
### middleware
|
|
1547
|
+
|
|
1548
|
+
- `s.middleware.call` [special]
|
|
1549
|
+
|
|
1550
|
+
### object
|
|
1551
|
+
|
|
1552
|
+
- `s.object.entries({ as?: string; value?: value })` [output] → as: [string, unknown][]
|
|
1553
|
+
- `s.object.keys({ as?: string; value?: value })` [output] → as: string[]
|
|
1554
|
+
- `s.object.values({ as?: string; value?: value })` [output] → as: unknown[]
|
|
1555
|
+
|
|
1556
|
+
### realtime
|
|
1557
|
+
|
|
1558
|
+
- `s.realtime.get_session({ as?: string })`
|
|
1559
|
+
- `s.realtime.publish` [special]
|
|
1560
|
+
|
|
1561
|
+
### redis
|
|
1562
|
+
|
|
1563
|
+
- `s.redis.count({ as?: string; key: value })`
|
|
1564
|
+
- `s.redis.decr({ as?: string; key: value; by?: value })`
|
|
1565
|
+
- `s.redis.del({ key: value })`
|
|
1566
|
+
- `s.redis.get({ as?: string; key: value })`
|
|
1567
|
+
- `s.redis.has({ as?: string; key: value })`
|
|
1568
|
+
- `s.redis.incr({ as?: string; key: value; by?: value })`
|
|
1569
|
+
- `s.redis.keys({ as?: string; search: value })`
|
|
1570
|
+
- `s.redis.pop({ as?: string; key: value; count?: value })`
|
|
1571
|
+
- `s.redis.push({ as?: string; key: value; value: value })`
|
|
1572
|
+
- `s.redis.range({ as?: string; key: value; start?: value; stop?: value })`
|
|
1573
|
+
- `s.redis.ratelimit({ as?: string; key: value; max?: value; ttl?: value; error?: value })`
|
|
1574
|
+
- `s.redis.remove({ as?: string; key: value; value: value; count?: value })`
|
|
1575
|
+
- `s.redis.set({ as?: string; key: value; data: value; ttl?: value; create_only?: value })`
|
|
1576
|
+
- `s.redis.shift({ as?: string; key: value; count?: value })`
|
|
1577
|
+
- `s.redis.unshift({ as?: string; key: value; value: value })`
|
|
1578
|
+
|
|
1579
|
+
### security
|
|
1580
|
+
|
|
1581
|
+
- `s.security.check_password({ as?: string; text_password?: value; hash_password?: value })` [output] → as: boolean (true when the plaintext matches the stored hash. ⚠ input.password double-hashes — pass input.text() plaintext)
|
|
1582
|
+
- `s.security.create_auth_token` [special]
|
|
1583
|
+
- `s.security.create_curve_key({ as?: string; curve?: "P-256" | "P-384" | "P-521"; format?: "object" | "base64" })`
|
|
1584
|
+
- `s.security.create_guid` [special]
|
|
1585
|
+
- `s.security.create_password({ as?: string; character_count?: value; require_lowercase?: value; require_uppercase?: value; require_digit?: value; require_symbol?: value; symbol_whitelist?: value })`
|
|
1586
|
+
- `s.security.create_rsa_key({ as?: string; bits?: value; format?: "object" | "base64" })`
|
|
1587
|
+
- `s.security.create_secret_key({ as?: string; bits?: value; format?: "object" | "base64" })`
|
|
1588
|
+
- `s.security.create_uuid({ as?: string })`
|
|
1589
|
+
- `s.security.decrypt({ as?: string; data?: value; algorithm?: "aes-128-cbc" | "aes-192-cbc" | "aes-256-cbc" | "aes-128-gcm" | "aes-192-gcm" | "aes-256-gcm"; key?: value; iv?: value })`
|
|
1590
|
+
- `s.security.encrypt({ as?: string; data?: value; algorithm?: "aes-128-cbc" | "aes-192-cbc" | "aes-256-cbc" | "aes-128-gcm" | "aes-192-gcm" | "aes-256-gcm"; key?: value; iv?: value })`
|
|
1591
|
+
- `s.security.jwe_decode({ as?: string; token?: value; key?: value; check_claims?: value; key_algorithm?: "A128KW" | "A192KW" | "A256KW" | "A128GCMKW" | "A192GCMKW" | "A256GCMKW" | "ECDH-ES+A128KW" | "ECDH-ES+A192KW" | "ECDH-ES+A256KW"; content_algorithm?: "A128GCM" | "A192GCM" | "A256GCM" | "A128CBC-HS256" | "A192CBC-HS384" | "A256CBC-HS512"; timeDrift?: value })`
|
|
1592
|
+
- `s.security.jwe_encode({ as?: string; headers?: value; claims?: value; key?: value; key_algorithm?: "A128KW" | "A192KW" | "A256KW" | "A128GCMKW" | "A192GCMKW" | "A256GCMKW" | "ECDH-ES+A128KW" | "ECDH-ES+A192KW" | "ECDH-ES+A256KW"; content_algorithm?: "A128GCM" | "A192GCM" | "A256GCM" | "A128CBC-HS256" | "A192CBC-HS384" | "A256CBC-HS512"; ttl?: value })`
|
|
1593
|
+
- `s.security.jws_decode({ as?: string; token?: value; key?: value; check_claims?: value; signature_algorithm?: "PS256" | "PS384" | "PS512" | "RS256" | "RS384" | "RS512" | "HS256" | "HS384" | "HS512" | "ES256" | "ES384" | "ES512"; timeDrift?: value })`
|
|
1594
|
+
- `s.security.jws_encode({ as?: string; headers?: value; claims?: value; key?: value; signature_algorithm?: "PS256" | "PS384" | "PS512" | "RS256" | "RS384" | "RS512" | "HS256" | "HS384" | "HS512" | "ES256" | "ES384" | "ES512"; ttl?: value })`
|
|
1595
|
+
- `s.security.random_bytes({ as?: string; length?: value })`
|
|
1596
|
+
- `s.security.random_number({ as?: string; min?: value; max?: value })`
|
|
1597
|
+
|
|
1598
|
+
### storage
|
|
1599
|
+
|
|
1600
|
+
- `s.storage.create_attachment({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value; include_meta?: boolean; type?: value })`
|
|
1601
|
+
- `s.storage.create_audio({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value })` [output]
|
|
1602
|
+
- `s.storage.create_file_resource({ as?: string; filename: value; filedata: value })` [output]
|
|
1603
|
+
- `s.storage.create_image({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value })` [output]
|
|
1604
|
+
- `s.storage.create_video({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value })` [output]
|
|
1605
|
+
- `s.storage.delete_file({ pathname: value })` [output]
|
|
1606
|
+
- `s.storage.read_file_resource({ as?: string; value: value })`
|
|
1607
|
+
- `s.storage.sign_private_url({ as?: string; pathname: value; ttl?: value })`
|
|
1608
|
+
|
|
1609
|
+
### stream
|
|
1610
|
+
|
|
1611
|
+
- `s.stream.from_csv({ as?: string; value: value; separator?: value; enclosure?: value; escape_char?: value })` [output]
|
|
1612
|
+
- `s.stream.from_jsonl({ as?: string; value: value })`
|
|
1613
|
+
- `s.stream.from_request` [special]
|
|
1614
|
+
|
|
1615
|
+
### task
|
|
1616
|
+
|
|
1617
|
+
- `s.task.call` [special]
|
|
1618
|
+
|
|
1619
|
+
### text
|
|
1620
|
+
|
|
1621
|
+
- `s.text.append({ name?: string; value: value })`
|
|
1622
|
+
- `s.text.contains({ name?: string; as?: string; value: value })`
|
|
1623
|
+
- `s.text.ends_with({ name?: string; as?: string; value: value })`
|
|
1624
|
+
- `s.text.icontains({ name?: string; as?: string; value: value })`
|
|
1625
|
+
- `s.text.iends_with({ name?: string; as?: string; value: value })`
|
|
1626
|
+
- `s.text.istarts_with({ name?: string; as?: string; value: value })`
|
|
1627
|
+
- `s.text.ltrim({ name?: string; value: value })`
|
|
1628
|
+
- `s.text.prepend({ name?: string; value: value })`
|
|
1629
|
+
- `s.text.rtrim({ name?: string; value: value })`
|
|
1630
|
+
- `s.text.starts_with({ name?: string; as?: string; value: value })`
|
|
1631
|
+
- `s.text.trim({ name?: string; value: value })`
|
|
1632
|
+
|
|
1633
|
+
### tool
|
|
1634
|
+
|
|
1635
|
+
- `s.tool.call` [special]
|
|
1636
|
+
|
|
1637
|
+
### trigger
|
|
1638
|
+
|
|
1639
|
+
- `s.trigger.call` [special]
|
|
1640
|
+
|
|
1641
|
+
### util
|
|
1642
|
+
|
|
1643
|
+
- `s.util.geo_distance({ as?: string; latitude_1?: value; longitude_1?: value; latitude_2?: value; longitude_2?: value })` → as: number (great-circle distance in METRES (a decimal) — divide by 1000 for km. Identical points return 0)
|
|
1644
|
+
- `s.util.get_all_input({ as?: string })`
|
|
1645
|
+
- `s.util.get_env({ as?: string })`
|
|
1646
|
+
- `s.util.get_input` [special]
|
|
1647
|
+
- `s.util.get_raw_input` [special]
|
|
1648
|
+
- `s.util.get_vars({ as?: string })`
|
|
1649
|
+
- `s.util.ip_lookup({ as?: string; value: value })` → as: IpLookupResult | null (NESTED, not flat: { continent: {code,name}, country: {code,name}, region: {code,name}, city: {name}, postal: {code}, location: {latitude, longitude, tz, radius} }. Coordinates are ref("geo.location.latitude"/".longitude"), place names ref("geo.city.name"/"geo.region.name"/"geo.country.name"); radius is KILOMETRES. ⚠ Every leaf is nullable and region/city/postal commonly ARE null for a routable public address — that is a normal hit, not a failed lookup. `city` is an OBJECT, so a bare ref("geo.city") into a text column fails on the object and { safe: true } does NOT help; drill to city.name with a fallback. The whole var is null for an unresolvable address)
|
|
1650
|
+
- `s.util.post_process` [special]
|
|
1651
|
+
- `s.util.send_email({ as?: string; service_provider?: "resend" | "xano"; api_key?: value; subject?: value; message?: value; to?: value; bcc?: value; cc?: value; from?: value; reply_to?: value; scheduled_at?: value })`
|
|
1652
|
+
- `s.util.set_header({ value: value; duplicates?: "replace" | "append" })`
|
|
1653
|
+
- `s.util.sleep({ value: value })`
|
|
1654
|
+
- `s.util.template_engine({ as?: string; value: value })`
|
|
1655
|
+
|
|
1656
|
+
### webflow
|
|
1657
|
+
|
|
1658
|
+
- `s.webflow.request` [special]
|
|
1659
|
+
|
|
1660
|
+
### workflow_test
|
|
1661
|
+
|
|
1662
|
+
Run another workflow test from inside one. Pass the `workflowTest()` def handle, not a name.
|
|
1663
|
+
|
|
1664
|
+
- `s.workflow_test.call` [special]
|
|
1665
|
+
|
|
1666
|
+
### zip
|
|
1667
|
+
|
|
1668
|
+
- `s.zip.add_to_archive({ file: value; filename: value; zip: value; password?: value; password_encryption?: value })`
|
|
1669
|
+
- `s.zip.create_archive({ as?: string; filename: value; password?: value; password_encryption?: "standard" | "AES-128" | "AES-192" | "AES-256" })`
|
|
1670
|
+
- `s.zip.delete_from_archive({ filename: value; zip: value; password?: value })`
|
|
1671
|
+
- `s.zip.extract({ as?: string; zip: value; password?: value })`
|
|
1672
|
+
- `s.zip.view_contents({ as?: string; zip: value; password?: value })`
|