@xano/sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +788 -0
- package/LICENSE +21 -0
- package/README.md +541 -0
- package/dist/.build-fingerprint +1 -0
- package/dist/agent-file-refresh-ZEMK33AX.js +38 -0
- package/dist/answer-shape-K67TAAPY.js +33 -0
- package/dist/backend-dir-2U53CIAL.js +39 -0
- package/dist/backend-slot-7QXW2QYC.js +28 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +255 -0
- package/dist/branch-NCRNWQBQ.js +61 -0
- package/dist/branch-commands-ACIRCEQJ.js +79 -0
- package/dist/bundle.d.ts +223 -0
- package/dist/bundle.js +153 -0
- package/dist/capture-WAXPD7DR.js +41 -0
- package/dist/chunk-27GWNP2I.js +23 -0
- package/dist/chunk-2CMJZDCL.js +177 -0
- package/dist/chunk-2EZ3ZJZZ.js +302 -0
- package/dist/chunk-2GNFDZA2.js +390 -0
- package/dist/chunk-2MNLRIOX.js +11 -0
- package/dist/chunk-2TFRR6JV.js +104 -0
- package/dist/chunk-2ZUEE4EK.js +408 -0
- package/dist/chunk-36JUQZVQ.js +22 -0
- package/dist/chunk-3DRXWLJ5.js +83 -0
- package/dist/chunk-3JDU4VRL.js +137 -0
- package/dist/chunk-3KZCLN4Z.js +241 -0
- package/dist/chunk-3LDULPRE.js +168 -0
- package/dist/chunk-3M4LFYNQ.js +111 -0
- package/dist/chunk-3XI5CTUE.js +22 -0
- package/dist/chunk-3YW23UFH.js +50 -0
- package/dist/chunk-45UIT5T2.js +862 -0
- package/dist/chunk-4BW2SG2N.js +46 -0
- package/dist/chunk-4KHEMC32.js +79 -0
- package/dist/chunk-4POWHT2K.js +321 -0
- package/dist/chunk-4Q2GN7NW.js +192 -0
- package/dist/chunk-5A2PLKZB.js +70 -0
- package/dist/chunk-5FBYXK5P.js +159 -0
- package/dist/chunk-5HVXPTU4.js +323 -0
- package/dist/chunk-5QU2P5CA.js +304 -0
- package/dist/chunk-5V5XU32D.js +191 -0
- package/dist/chunk-5ZSO4T2G.js +10 -0
- package/dist/chunk-622HUCZT.js +10 -0
- package/dist/chunk-65HWOMGV.js +177 -0
- package/dist/chunk-6ELFRN3P.js +365 -0
- package/dist/chunk-6HO3NCSJ.js +1055 -0
- package/dist/chunk-6IUMKYWI.js +457 -0
- package/dist/chunk-6KEB3C3U.js +65 -0
- package/dist/chunk-6MR5TI5V.js +987 -0
- package/dist/chunk-6NNTNXN7.js +157 -0
- package/dist/chunk-6PBR37LN.js +97 -0
- package/dist/chunk-6QXLMMT3.js +155 -0
- package/dist/chunk-6ZGJCBZS.js +224 -0
- package/dist/chunk-73IVBKMA.js +250 -0
- package/dist/chunk-7G3CTU7I.js +286 -0
- package/dist/chunk-A3UDOZB6.js +222 -0
- package/dist/chunk-A6E77GDW.js +85 -0
- package/dist/chunk-AHJWCYQO.js +660 -0
- package/dist/chunk-AKNCFVKZ.js +36 -0
- package/dist/chunk-AM4FKL4J.js +237 -0
- package/dist/chunk-AN6BL47T.js +26 -0
- package/dist/chunk-AOSYVMUS.js +147 -0
- package/dist/chunk-APWXY5SL.js +182 -0
- package/dist/chunk-AWEIIR2U.js +218 -0
- package/dist/chunk-AWQ7BQTQ.js +73 -0
- package/dist/chunk-BAUZOWGX.js +393 -0
- package/dist/chunk-BHACRS6R.js +940 -0
- package/dist/chunk-BWPNT55T.js +680 -0
- package/dist/chunk-C6RIMRU4.js +187 -0
- package/dist/chunk-CGVQZXYG.js +263 -0
- package/dist/chunk-CIMQZRRE.js +162 -0
- package/dist/chunk-CJHQKJU4.js +113 -0
- package/dist/chunk-CTMO46PK.js +1897 -0
- package/dist/chunk-D53J55C5.js +106 -0
- package/dist/chunk-D7VGG3WS.js +90 -0
- package/dist/chunk-DAD3ONIS.js +245 -0
- package/dist/chunk-DGWHAVRG.js +162 -0
- package/dist/chunk-DX4QR4B2.js +483 -0
- package/dist/chunk-EHP3WPEG.js +21 -0
- package/dist/chunk-ELP2HMXC.js +432 -0
- package/dist/chunk-ERVIASGL.js +21 -0
- package/dist/chunk-ET4Y4XCB.js +865 -0
- package/dist/chunk-ETO6LGLD.js +4750 -0
- package/dist/chunk-EUUUMY7J.js +24 -0
- package/dist/chunk-FQJ2TTHP.js +218 -0
- package/dist/chunk-FQOF7E2E.js +133 -0
- package/dist/chunk-FWD7AL6N.js +21 -0
- package/dist/chunk-FXFV3KU6.js +138 -0
- package/dist/chunk-FXRQOWTE.js +171 -0
- package/dist/chunk-G554NZNI.js +210 -0
- package/dist/chunk-GSGDWJB5.js +13 -0
- package/dist/chunk-H2V3N23D.js +66 -0
- package/dist/chunk-H5C7MBCR.js +159 -0
- package/dist/chunk-HHN734BU.js +371 -0
- package/dist/chunk-HRVBLCVJ.js +243 -0
- package/dist/chunk-HVRUQXGD.js +1264 -0
- package/dist/chunk-I4F5TCJH.js +130 -0
- package/dist/chunk-IDOTCMDV.js +57 -0
- package/dist/chunk-IGOLHKWF.js +427 -0
- package/dist/chunk-JGE7CAWB.js +148 -0
- package/dist/chunk-JTUKLCXS.js +92 -0
- package/dist/chunk-JZGVVJSI.js +66 -0
- package/dist/chunk-K4WX6UUU.js +923 -0
- package/dist/chunk-K5BBGTYZ.js +217 -0
- package/dist/chunk-K6RXBGUB.js +427 -0
- package/dist/chunk-KTSDSD36.js +165 -0
- package/dist/chunk-L5JYN3NM.js +1038 -0
- package/dist/chunk-LIEOKXKD.js +354 -0
- package/dist/chunk-LLNZGEFK.js +51 -0
- package/dist/chunk-LPECSJ2J.js +55 -0
- package/dist/chunk-LQCMKAJ7.js +413 -0
- package/dist/chunk-LRN4ZJCQ.js +1145 -0
- package/dist/chunk-MJ4ZVHKN.js +17 -0
- package/dist/chunk-MXHSQOC4.js +3061 -0
- package/dist/chunk-N5GKCWE5.js +367 -0
- package/dist/chunk-NA3PXMEF.js +10 -0
- package/dist/chunk-NAQDMEHV.js +76 -0
- package/dist/chunk-NWKXM5RD.js +109 -0
- package/dist/chunk-NZOWWHU5.js +196 -0
- package/dist/chunk-OHX6MIUZ.js +184 -0
- package/dist/chunk-OOXWYAA4.js +15 -0
- package/dist/chunk-OPHB5ORL.js +1990 -0
- package/dist/chunk-OXA6PSZT.js +1725 -0
- package/dist/chunk-P2HAGF6H.js +73 -0
- package/dist/chunk-PTHVMXZI.js +3036 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-Q325JKYB.js +189 -0
- package/dist/chunk-Q7DGTQ5G.js +255 -0
- package/dist/chunk-QCNYHBL7.js +601 -0
- package/dist/chunk-QO6BVGS5.js +145 -0
- package/dist/chunk-QXTKSOFI.js +76 -0
- package/dist/chunk-ROY4YAEQ.js +66 -0
- package/dist/chunk-RTJRZFFY.js +447 -0
- package/dist/chunk-SAVJUS2X.js +128 -0
- package/dist/chunk-SFLHVCKX.js +173 -0
- package/dist/chunk-STSFNCY4.js +127 -0
- package/dist/chunk-SWBF2LTK.js +170 -0
- package/dist/chunk-T5KELPAR.js +109 -0
- package/dist/chunk-TFFGOFVZ.js +1481 -0
- package/dist/chunk-TLHIFK4M.js +256 -0
- package/dist/chunk-TLQE7ZN3.js +156 -0
- package/dist/chunk-TPHWJ7UG.js +1981 -0
- package/dist/chunk-TRJCALW3.js +291 -0
- package/dist/chunk-TTEIYD5P.js +133 -0
- package/dist/chunk-U64O2MXY.js +407 -0
- package/dist/chunk-UCQ2VUTG.js +630 -0
- package/dist/chunk-UTI5G6OJ.js +44 -0
- package/dist/chunk-UVHDMMIP.js +188 -0
- package/dist/chunk-UZ6QQWJT.js +120 -0
- package/dist/chunk-VAX3UZOY.js +686 -0
- package/dist/chunk-W3HBUKFO.js +57 -0
- package/dist/chunk-WKQLGKCL.js +2823 -0
- package/dist/chunk-WQFNASK4.js +560 -0
- package/dist/chunk-WRBKLYSJ.js +224 -0
- package/dist/chunk-WTPZRR4I.js +17 -0
- package/dist/chunk-WZDJ2LLX.js +2385 -0
- package/dist/chunk-X2NL4KUO.js +14 -0
- package/dist/chunk-X4DVXBFY.js +38 -0
- package/dist/chunk-X677DMZM.js +7050 -0
- package/dist/chunk-XBKMWY7F.js +612 -0
- package/dist/chunk-XNCP4KYO.js +78 -0
- package/dist/chunk-XVBGLISP.js +35 -0
- package/dist/chunk-XW6KISBB.js +1192 -0
- package/dist/chunk-YGZHHAAB.js +8156 -0
- package/dist/chunk-YHEFQUQB.js +186 -0
- package/dist/chunk-YLDALTWZ.js +653 -0
- package/dist/chunk-YU4HSQW7.js +229 -0
- package/dist/chunk-ZDPP6RKB.js +27 -0
- package/dist/chunk-ZFDZKNGG.js +194 -0
- package/dist/chunk-ZIY7RNUT.js +49 -0
- package/dist/chunk-ZYJP6BOO.js +30 -0
- package/dist/cli.d.ts +1111 -0
- package/dist/cli.js +165 -0
- package/dist/codegen-command-VR2GWR6C.js +173 -0
- package/dist/codegen.d.ts +115 -0
- package/dist/codegen.js +19 -0
- package/dist/commands-GJMJ4XYZ.js +76 -0
- package/dist/completion-ZQLO6PB6.js +738 -0
- package/dist/config-MBYGCAFK.js +136 -0
- package/dist/deploy-command-JVZPWHMK.js +180 -0
- package/dist/deploy-source-3W45CXMP.js +81 -0
- package/dist/dev-env-sync-HN44K4D5.js +188 -0
- package/dist/diagnostics-UEAAHWVF.js +30 -0
- package/dist/display-name-hint-ABBPVQIR.js +46 -0
- package/dist/emit-CyhBDxli.d.ts +7007 -0
- package/dist/env-example-refresh-ET5HMMDW.js +126 -0
- package/dist/env-pull-command-TNMNB3CV.js +323 -0
- package/dist/env-target-O4HRKTGZ.js +40 -0
- package/dist/env-var-command-ZIRDCAZA.js +427 -0
- package/dist/ephemeral-P7F3PMAD.js +54 -0
- package/dist/ephemeral-command-CLJJKOFT.js +592 -0
- package/dist/ephemeral-state-PYCBSOGX.js +61 -0
- package/dist/file-lock-PT74GWYW.js +10 -0
- package/dist/generate-command-AYEOLA26.js +510 -0
- package/dist/gitignore-3SJ3WNRG.js +51 -0
- package/dist/guards-4EBNCZDC.js +162 -0
- package/dist/hosted-file-PSHJB3HX.js +28 -0
- package/dist/impersonate-command-5JAS3WSV.js +152 -0
- package/dist/import-EQSFU6JC.js +19 -0
- package/dist/index.d.ts +5470 -0
- package/dist/index.js +371 -0
- package/dist/init-command-X2FR4UAK.js +125 -0
- package/dist/init-modules-RY7UHAOL.js +50 -0
- package/dist/init-web-M4YZSZK6.js +42 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +2634 -0
- package/dist/io-RC56QSN5.js +26 -0
- package/dist/keep-data-merge-7MPLVAVI.js +1119 -0
- package/dist/lambda-config-reconcile-OUZ4OYAB.js +116 -0
- package/dist/landed-753YLJ5H.js +40 -0
- package/dist/landing-record-SU52SKXU.js +61 -0
- package/dist/live-diff-YONW2VI3.js +65 -0
- package/dist/local-engine-choice-CH5X3W7E.js +79 -0
- package/dist/local-engine-command-Z3OL7VOD.js +766 -0
- package/dist/local-engine-config-5U3SC434.js +39 -0
- package/dist/local-engine-deploy-PCQ4SNPB.js +39 -0
- package/dist/local-engine-process-EHTE5LLU.js +63 -0
- package/dist/local-engine-state-A4FTRFAF.js +32 -0
- package/dist/local-engine-update-check-6L645VBL.js +162 -0
- package/dist/lock-MRRHW5GS.js +87 -0
- package/dist/lock-commands-2QG25HL7.js +963 -0
- package/dist/login-command-QPYRWOXZ.js +538 -0
- package/dist/logout-command-WOIQLDP6.js +56 -0
- package/dist/loop-ALIZLOSX.js +191 -0
- package/dist/marketplace-command-K5YL42NO.js +977 -0
- package/dist/marketplace-resolve-LY32JES5.js +40 -0
- package/dist/meta-client-YXGPW53C.js +244 -0
- package/dist/module-manifest-JFGHU2TC.js +38 -0
- package/dist/near-key.d.ts +13 -0
- package/dist/near-key.js +8 -0
- package/dist/node.d.ts +95 -0
- package/dist/node.js +969 -0
- package/dist/operation-outcome-TZSESNC7.js +51 -0
- package/dist/output-target-AL3I44GG.js +47 -0
- package/dist/package-manager-PRAHUSCN.js +34 -0
- package/dist/plan-disclosure-6G4MLZKD.js +114 -0
- package/dist/plugin-contributions-WRNY5ABG.js +28 -0
- package/dist/plugin-prompt-4VA3B4FF.js +20 -0
- package/dist/plugin-questions-JTSYA74Q.js +24 -0
- package/dist/plugin.d.ts +396 -0
- package/dist/plugin.js +1 -0
- package/dist/preflight-command-2T5Z36MI.js +321 -0
- package/dist/profile-command-AH3PIM4X.js +800 -0
- package/dist/profile-pointer-GRWJU3YD.js +31 -0
- package/dist/profile-select-74TRDEPD.js +23 -0
- package/dist/project-dependency-KSH63ODT.js +35 -0
- package/dist/project-sdk-REPPOIBD.js +24 -0
- package/dist/promote-command-PCWT5N6S.js +140 -0
- package/dist/prompt-II3F3LND.js +27 -0
- package/dist/publish-command-TTDUUJI5.js +414 -0
- package/dist/pull-command-VBO7C4S3.js +161 -0
- package/dist/pull-superseded-GJWLG7XO.js +20 -0
- package/dist/release-command-N4ZPKMHR.js +146 -0
- package/dist/release-ns-command-3KFDMZ6G.js +1745 -0
- package/dist/response-HlXahv4K.d.ts +925 -0
- package/dist/retry-command-AS5EVREX.js +34 -0
- package/dist/routes-manifest-SNWUVBT6.js +43 -0
- package/dist/runtime-XETHVF33.js +14 -0
- package/dist/scaffold.d.ts +1911 -0
- package/dist/scaffold.js +107 -0
- package/dist/secrets-file-LVZD6IIM.js +56 -0
- package/dist/secrets-fill-command-TTCPSVXN.js +218 -0
- package/dist/seed-VU7C3QYS.js +46 -0
- package/dist/sent-writes-N7X4OVXT.js +22 -0
- package/dist/source-resolve-S2XA3XZF.js +83 -0
- package/dist/static-host-B2Y6AG3W.js +352 -0
- package/dist/status-command-RUDXTF7I.js +554 -0
- package/dist/store-6VNCD4D7.js +20 -0
- package/dist/store-ARM6VMO5.js +98 -0
- package/dist/store-DuYfzKlW.d.ts +3144 -0
- package/dist/suggest-6FOOYKHG.js +12 -0
- package/dist/sveltekit.d.ts +154 -0
- package/dist/sveltekit.js +109 -0
- package/dist/sync-contributions-UCEROO26.js +415 -0
- package/dist/table-ENAVMITR.js +42 -0
- package/dist/tables-command-GUPXXSB5.js +156 -0
- package/dist/tenant-IIHPOHGP.js +42 -0
- package/dist/tenant-command-D5HGI2X4.js +965 -0
- package/dist/test-command-TIKZMDPZ.js +476 -0
- package/dist/tests-BGGIOK3L.js +26 -0
- package/dist/token-NICJUTMT.js +72 -0
- package/dist/toolchain-hooks-GHKLUJOH.js +96 -0
- package/dist/toolchain-modules-5EAZUONB.js +24 -0
- package/dist/tracked-backend-EIBUMDZA.js +80 -0
- package/dist/update-check-2W2NLDTC.js +136 -0
- package/dist/upgrade-command-QHNFWM7O.js +531 -0
- package/dist/verify-rollout-DRCK5TDD.js +61 -0
- package/dist/whoami-command-STGINTXH.js +51 -0
- package/dist/workspace-2CLHU2LK.js +93 -0
- package/dist/workspace-binding-56PIIPCP.js +54 -0
- package/dist/workspace-command-JEXQETJD.js +535 -0
- package/dist/workspace-env-F2RFMSC5.js +70 -0
- package/dist/workspace-export-OJSH2HIW.js +21 -0
- package/dist/writable-VA7OISSK.js +25 -0
- package/dist/xano-ICFUSPFR.js +42 -0
- package/dist/xanosdk-import-GN42TO2B.js +56 -0
- package/dist/xanosdk-project-S6DXJONQ.js +41 -0
- package/dist/xdo-CxDr2Q1C.d.ts +1371 -0
- package/guides/README.md +33 -0
- package/guides/authoring.md +921 -0
- package/guides/cli.md +401 -0
- package/guides/codegen.md +177 -0
- package/guides/codes.md +404 -0
- package/guides/coverage.md +74 -0
- package/guides/deploying.md +1525 -0
- package/guides/environment.md +400 -0
- package/guides/marketplace.md +415 -0
- package/guides/module-contract.md +558 -0
- package/guides/object-kinds.md +433 -0
- package/guides/project-structure.md +43 -0
- package/guides/scaffold.md +351 -0
- package/guides/typed-frontend.md +242 -0
- package/llms/client.md +63 -0
- package/llms/errors.md +56 -0
- package/llms/fields.md +176 -0
- package/llms/filters.md +183 -0
- package/llms/kinds-agent-mcp.md +30 -0
- package/llms/kinds-core.md +100 -0
- package/llms/kinds-knowledge.md +20 -0
- package/llms/kinds-realtime.md +76 -0
- package/llms/lambda.md +94 -0
- package/llms/legacy.md +44 -0
- package/llms/lock.md +40 -0
- package/llms/object-kinds.md +34 -0
- package/llms/statements-calls.md +62 -0
- package/llms/statements-catalog.md +320 -0
- package/llms/statements-data.md +66 -0
- package/llms/statements-runtime.md +68 -0
- package/llms/tests.md +59 -0
- package/llms/toolchain.md +16 -0
- package/llms/triggers.md +36 -0
- package/llms/values.md +29 -0
- package/llms-full.txt +2085 -0
- package/llms.txt +493 -0
- package/manifest.json +11224 -0
- package/package.json +162 -0
- package/types/lambda-globals.d.ts +129 -0
package/llms-full.txt
ADDED
|
@@ -0,0 +1,2085 @@
|
|
|
1
|
+
# xanosdk v1.0.0
|
|
2
|
+
|
|
3
|
+
> TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
|
|
4
|
+
|
|
5
|
+
Xano is a hosted backend platform: one WORKSPACE serves a managed PostgreSQL
|
|
6
|
+
database, HTTP API endpoints, background tasks, realtime websocket channels, AI
|
|
7
|
+
agents and MCP servers, file storage, and redis. This SDK authors that workspace in
|
|
8
|
+
TypeScript — typed def objects registered on a `workspace(name)` — compiled to one
|
|
9
|
+
importable bundle by `xanosdk export ./xano/index.ts`, shipped live by `xanosdk deploy`.
|
|
10
|
+
|
|
11
|
+
Def modules execute at BUILD time only: `s.*` factories return data, and the engine
|
|
12
|
+
runs the compiled stack per request — statements in order, a statement's `as:`
|
|
13
|
+
naming a runtime variable, `response` the HTTP body. Every dynamic operand is a
|
|
14
|
+
TAGGED value — `ref("x")` a stack variable, `inp("x")` an input, `auth("id")` the
|
|
15
|
+
caller, `c.*` a constant — resolved at request time; JS operators over them do not
|
|
16
|
+
compute (see Gotchas). Requests share no memory — state persists in tables or redis.
|
|
17
|
+
|
|
18
|
+
Authorable: statement surfaces 218/218, filters 246 (246 typed).
|
|
19
|
+
Not authorable here: branch, market_item, realtime_channel, run.job, run.service, tablemap — these cannot be authored and do not survive a pull; reasons in `coverage.objectKinds.unmodeled`.
|
|
20
|
+
|
|
21
|
+
This file is the whole always-read surface: the mental model, the deploy contract,
|
|
22
|
+
every cross-cutting gotcha, and control flow. Per-surface detail lives in the topic files listed
|
|
23
|
+
below — open the one whose condition matches the task, skip the rest. For
|
|
24
|
+
exhaustive per-entry detail in NEITHER — a statement's field schema with engine
|
|
25
|
+
defaults, a filter's full argument list, the `storedName` mapping — do a TARGETED
|
|
26
|
+
lookup in the shipped `manifest.json` (a program imports it as
|
|
27
|
+
`@xano/sdk/manifest.json`, needing `with { type: "json" }` in Node ESM;
|
|
28
|
+
it is ~70k 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:
|
|
29
|
+
jq '.statements[] | select(.sPath=="db.get")' manifest.json
|
|
30
|
+
jq '.filters[] | select(.name=="json_decode")' manifest.json
|
|
31
|
+
⚠ `fields` is null on the 65 `declarative: false` statements (`db.get`, …): typed wrappers whose arguments are the factory's `.d.ts` signature. Null ≠ missing.
|
|
32
|
+
Select a statement on `sPath` (the `s.*` path you write), NOT `surface` (the XanoScript term): 24 of 218 differ — `var`→`set_var`, `break`→`foreach_break`, `foreach.remove`→`foreach_remove`, and every `expect.*`.
|
|
33
|
+
|
|
34
|
+
## Topic files
|
|
35
|
+
|
|
36
|
+
Paths are relative to this file (`node_modules/@xano/sdk/` once installed), so a
|
|
37
|
+
plain file read resolves them at the version you have.
|
|
38
|
+
`llms-full.txt` is everything concatenated — one fetch for a reader that cannot open files.
|
|
39
|
+
|
|
40
|
+
- [Object kinds](llms/object-kinds.md): Read when deciding WHAT to build — every authorable primitive, what each is, and which factory + register method builds it. Also when `register*` will not typecheck an array built by `.flatMap()`/`.concat()`.
|
|
41
|
+
- [Core def shapes](llms/kinds-core.md): Read when authoring a function, query, api group, task, workflow test, middleware, tool, or addon — and for the `response` and `expr` shapes every one of them uses.
|
|
42
|
+
- [Consuming defs from a client](llms/client.md): Read when a frontend imports a def (`getPath()`/`InferResponse`) or calls the deployed backend — what the import costs, `window.XANO_HOST`, local dev, file URLs, and a Node spot-check.
|
|
43
|
+
- [Toolchain modules](llms/toolchain.md): Read when `package.json` has a `"xanosdk"` block or you run `xanosdk marketplace`.
|
|
44
|
+
- [Error index](llms/errors.md): Read when a request, test, build, or pull fails with a message you did not write — the string, its cause, and where the fix is.
|
|
45
|
+
- [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 needs a `mock`, or when running a deployed environment's tests.
|
|
46
|
+
- [Agent and MCP def shapes](llms/kinds-agent-mcp.md): Read when defining an `agent()`, `mcpServer()`, `prompt()` or `resource()`.
|
|
47
|
+
- [Knowledge def shape](llms/kinds-knowledge.md): Read when the workspace defines a `knowledge()` item for its AI agents.
|
|
48
|
+
- [Realtime def shapes](llms/kinds-realtime.md): Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.
|
|
49
|
+
- [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.
|
|
50
|
+
- [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`).
|
|
51
|
+
- [Statement runtime behavior](llms/statements-runtime.md): Read when you need to know what a statement's `as:` output holds, or why a bound variable is not the shape expected.
|
|
52
|
+
- [Auth, cross-object calls, and microservices](llms/statements-calls.md): Read when the stack authenticates or verifies a webhook (recipes here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), sends email (`s.util.send_email`), or reaches a microservice.
|
|
53
|
+
- [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.
|
|
54
|
+
- [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 methods, and the `s.precondition` error/status contract. Also `table({ seed })` rows and `use_xdo`.
|
|
55
|
+
- [Filter catalog](llms/filters.md): Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
|
|
56
|
+
- [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`; each surface binds a different set of identifiers.
|
|
57
|
+
- [Lock file](llms/lock.md): Read when a `xano.lock` exists or should — renaming/pruning/importing identities, seeding the lock programmatically, or asking which commands write it.
|
|
58
|
+
- [Legacy paradigms and retired statements](llms/legacy.md): Read when the code was PULLED from an existing Xano instance — how a codegen'd tree reads, the shapes in it that must not be "fixed", and any `raw({ name: "mvp:…" })`, `realtimeTrigger()`, or name the catalogs do not list.
|
|
59
|
+
- [Statement catalog](llms/statements-catalog.md): Read for the field signature of a specific statement — every surface, grouped by `s.*` namespace.
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
Authoring is **declarative def-objects** passed to factories — there is no
|
|
64
|
+
callback/chaining builder.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { workspace, apiGroup, query, table, input, f, ref, inp, auth, s } from "@xano/sdk";
|
|
68
|
+
|
|
69
|
+
const users = table({
|
|
70
|
+
name: "users",
|
|
71
|
+
auth: true, // backs authentication
|
|
72
|
+
// `id` (int PK) + `created_at` (epochms) are auto-injected — don't declare them.
|
|
73
|
+
schema: {
|
|
74
|
+
email: f.email({ required: true }),
|
|
75
|
+
name: f.text(),
|
|
76
|
+
},
|
|
77
|
+
// Indexes: { type, fields: [{ name, op? }] }. `"unique"` is shorthand for `"btree|unique"`.
|
|
78
|
+
// A DOTTED name indexes a key inside a column (`xdo.email`); only the part before the
|
|
79
|
+
// first dot is checked against the schema.
|
|
80
|
+
index: [{ type: "unique", fields: [{ name: "email" }] }], // also: btree, hash, gin, search, gist, vector
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const posts = table({
|
|
84
|
+
name: "posts",
|
|
85
|
+
schema: {
|
|
86
|
+
author: f.tableRef(users), // foreign key → users (NOT `ref`)
|
|
87
|
+
body: f.text({ required: true }),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const api = apiGroup({ name: "blog", canonical: "blog" }); // canonical → the URL token
|
|
92
|
+
|
|
93
|
+
const createPost = query({
|
|
94
|
+
name: "create_post", verb: "POST", apiGroup: api, auth: users, // the auth table
|
|
95
|
+
input: { body: input.text({ required: true }) },
|
|
96
|
+
stack: [
|
|
97
|
+
s.db.add({ table: posts, row: { author: auth("id"), body: inp("body") }, as: "post" }),
|
|
98
|
+
],
|
|
99
|
+
response: ref("post"),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const listPosts = query({
|
|
103
|
+
name: "list_posts", verb: "GET", apiGroup: api,
|
|
104
|
+
stack: [s.db.query({ table: posts, sort: [{ sortBy: "created_at", dir: "desc" }], as: "rows" })],
|
|
105
|
+
response: ref("rows"),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export default workspace("my-blog")
|
|
109
|
+
.registerTables([users, posts])
|
|
110
|
+
.registerApiGroups([api])
|
|
111
|
+
.registerQueries([createPost, listPosts]);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Compile: `xanosdk export ./index.ts --out bundle.json` (or `writeBundle(app, path)`
|
|
115
|
+
from `@xano/sdk/node` in code). The default export must be the `Xano` registry. The entry must be an
|
|
116
|
+
ES module (Xano SDK defs are ESM-only): set `"type": "module"` in the nearest
|
|
117
|
+
package.json or name the entry `.mts`. `npm init -y` writes `"type": "commonjs"`,
|
|
118
|
+
which fails with a "must be ES modules" error until you switch it to module.
|
|
119
|
+
|
|
120
|
+
Set `canonical` on every `apiGroup`. The engine mints the URL token server-side, so
|
|
121
|
+
without one a group's client paths are unresolvable until a lock exists: the bundle
|
|
122
|
+
exports fine and `xanosdk routes` / `getPath()` then fail on the very queries it just
|
|
123
|
+
built. An explicit `canonical` resolves them from the source alone.
|
|
124
|
+
⚠ A canonical is unique across the whole INSTANCE, not per workspace — two
|
|
125
|
+
workspaces cannot serve the same one, and no flag shares it. One set in code is a PIN:
|
|
126
|
+
a merge refuses (exit 2) when another workspace holds it. One the lock minted is a
|
|
127
|
+
preference the instance may keep or replace with a random token, and `--static` is
|
|
128
|
+
refused when it does.
|
|
129
|
+
|
|
130
|
+
Build warnings: `export()` prints the shapes that deploy clean and then do the wrong
|
|
131
|
+
thing (a `bulk.update` zero-filling omitted columns, an `ignoreEmpty` on an empty
|
|
132
|
+
operand, a `ref()` nothing binds). Each has a legitimate use, so each only warns —
|
|
133
|
+
but nothing fails on a message no one reads, so in CI and in unattended agent builds
|
|
134
|
+
pass `--strict` (`emitBundle(app, { strict: true })` / `app.export({ strict: true })`):
|
|
135
|
+
every warning becomes a hard failure. Same bundle bytes either way.
|
|
136
|
+
Before calling the work done: `npm run typecheck`, `xanosdk export <entry> --strict`,
|
|
137
|
+
`xanosdk deploy <entry>`, then `xanosdk test run-all` against what deployed.
|
|
138
|
+
|
|
139
|
+
Identity: object guids derive from `(type, name)` — a query's from `(api group,
|
|
140
|
+
verb, name)` — so renames change identity.
|
|
141
|
+
EVERY build freezes every guid + api-group/toolset canonical in a lock file
|
|
142
|
+
BESIDE THE ENTRY FILE — `xano/xano.lock` for the standard scaffold,
|
|
143
|
+
NOT the project root — which you COMMIT (`--no-lock` opts out; CI guard
|
|
144
|
+
`--frozen-lock` fails instead of changing it).
|
|
145
|
+
To rename an object: rename in code, export (stderr prints the exact fix-up), run
|
|
146
|
+
`xanosdk lock rename <kind> <old> <new>`, export again — the original guid is emitted
|
|
147
|
+
under the new name, so the engine renames in place instead of delete+create. Taking
|
|
148
|
+
over an existing workspace: `xanosdk lock import <live-bundle.json>` first, then
|
|
149
|
+
export. Pruning, programmatic seeding, and which commands write the lock:
|
|
150
|
+
`llms/lock.md`.
|
|
151
|
+
|
|
152
|
+
## Deploy
|
|
153
|
+
|
|
154
|
+
`xanosdk init` → `xanosdk deploy` → URL. A deploy runs the same compile pipeline as
|
|
155
|
+
`export` (honoring `xano.lock`), imports the result, and prints the URL.
|
|
156
|
+
⚠ Every deploy is a FULL REPLACE of its destination — objects AND records.
|
|
157
|
+
|
|
158
|
+
**Backends** — one grammar: `workspace` | `ephemeral[:<name>]` | `local-engine[:<name>]`
|
|
159
|
+
(no credential) | `tenant:<name>` | `release:<name>` | a bundle path where a file fits. Reads
|
|
160
|
+
`--from`, writes `--to`, `test --on`; positional on `deploy`/`pull`/`generate`/`tables`/
|
|
161
|
+
`impersonate`. Omitted = the ephemeral or local engine this project last deployed to, never
|
|
162
|
+
workspace/tenant; bare `deploy` compiles the project onto an ephemeral instead (`--local-engine`:
|
|
163
|
+
an engine on this machine), and `generate`/`init --from` need one. A refusal, typed or tracked,
|
|
164
|
+
names the reason and the spellings (`publish`, `release create` refuse a local engine).
|
|
165
|
+
`release create --from` records a run: no bundle path, no `release:`,
|
|
166
|
+
and a `tenant:` only if throwaway (`standard`/`run` refused).
|
|
167
|
+
|
|
168
|
+
**Destinations** — ephemeral by default, real only when you say so:
|
|
169
|
+
|
|
170
|
+
- `xanosdk deploy [source]` → a NAMED, auto-expiring ephemeral (~1h; `--expires-hours`
|
|
171
|
+
1–24), tracked in `./.xano/ephemeral.json`, so deploying again REFRESHES it at the same
|
|
172
|
+
URL; if it expired or was swept, a fresh one is created and the new URL called out.
|
|
173
|
+
⚠ Only the BACKEND URL survives a refresh — the replace clears static hosting, so
|
|
174
|
+
`--static` publishes a NEW host every run and the previous URL stops serving
|
|
175
|
+
(not under `--keep-data`, which MERGES an env a deploy filled: rows kept, no re-seed,
|
|
176
|
+
removed objects deleted; ⚠ a rename loses values, a RETYPE reads unfit ones as null, env
|
|
177
|
+
VALUES are not applied, a table trigger refuses it — `--reset` replaces and re-seeds).
|
|
178
|
+
- `xanosdk release create <name>` cuts from a running source and LANDS in your workspace
|
|
179
|
+
— the record that it came up, and the only thing `promote`/`tenant deploy` take. A
|
|
180
|
+
taken name is refused, and so is one that addresses a LOCATION instead of naming: `/`,
|
|
181
|
+
`\`, `:`, or a leading `.`. Every export derives its FILENAME from the name it is given,
|
|
182
|
+
so `release|ephemeral|workspace export` refuse those same shapes unless `--path` names
|
|
183
|
+
the file. Carries EVERY table's SCHEMA either way; `--seed[=<guids>]` adds
|
|
184
|
+
ROWS — guids from `xanosdk tables <backend>`; a redeploy KEEPS them.
|
|
185
|
+
- `xanosdk release transfer <name> --to-profile <profile>` copies a release to another
|
|
186
|
+
workspace/instance by CONTENT (tar sha256): identical → reused; same name, other
|
|
187
|
+
bytes → exit 2.
|
|
188
|
+
- `xanosdk promote <release>` / `xanosdk tenant deploy <tenant> <release>` land it.
|
|
189
|
+
`promote` adds a branch; ⚠ its TABLE edits hit live
|
|
190
|
+
(`--allow-shared-schema-changes`). `tenant deploy` REPLACES: DROPS tables it lacks,
|
|
191
|
+
CONVERTS retypes (`--seed`: rows).
|
|
192
|
+
`promote` refuses a taken name/pinned slug (`SDK_IDENTITY_CONFLICT`), then
|
|
193
|
+
EXITS NON-ZERO if a declared object did not arrive or a pinned slug is not served;
|
|
194
|
+
`--set-live` waits on that (previous branch serving). Extra objects: reported;
|
|
195
|
+
a check missing an input reports `unverified`, exit 0. `--json` on every release write:
|
|
196
|
+
`completed` yes|no|unknown; UNKNOWN exits 9 — run `resolveWith`, never retry blind.
|
|
197
|
+
`--expect-live <label>` (with `--set-live`) refuses if live moved; not atomic. Inspect a staged branch with
|
|
198
|
+
`workspace export --branch <label>` or `workspace diff <entry> --branch <label>`.
|
|
199
|
+
⚠ The redis family, `s.lambda`, `s.db.direct_query`, `s.await`, `s.microservice.request`,
|
|
200
|
+
`s.zip.*`, the raw-file readers, Datadog and Webflow need an INSTANCE CAPABILITY: they
|
|
201
|
+
deploy clean and fail on the first request if the target lacks it. `export`/`deploy` list
|
|
202
|
+
what the bundle needs — compare it to the target yourself.
|
|
203
|
+
- Backend SECRETS stay out of the repo: declare the NAME, value empty, in `workspaceConfig({ env })`
|
|
204
|
+
passed to `registerWorkspace()`; the VALUE lives in `xano/.env` — gitignored, kept across a
|
|
205
|
+
pull, read by every build. `--backend-env-file <path>` replaces that default (CI has
|
|
206
|
+
no `xano/.env`), `--env-var KEY=VALUE` beats both, and `xanosdk env pull` fills the file from
|
|
207
|
+
a running backend — the only command that writes `xano/.env`.
|
|
208
|
+
⚠ A deploy REPLACES the env set: a declared name with NO value refuses the deploy rather
|
|
209
|
+
than clearing the live one — `--allow-empty-env=NAME` clears it on purpose.
|
|
210
|
+
ONE live value, no deploy: `xanosdk env set NAME` (value on stdin) / `env unset NAME`.
|
|
211
|
+
(`--static-env` is the other one — PUBLIC frontend config.)
|
|
212
|
+
- A DOCUMENTATION token is a secret too, in a SECOND ignored file: source declares only the
|
|
213
|
+
gate — `documentation: { require_token: true }` on `workspaceConfig` or an `apiGroup` — and
|
|
214
|
+
the value lives in `xano/.secrets.json`, keyed by the object it gates. A literal `token`
|
|
215
|
+
FAILS the export. `xanosdk pull` writes that file and every build reads it back;
|
|
216
|
+
`xanosdk secrets fill` MINTS one per gate that has none — a gate you AUTHORED has no value
|
|
217
|
+
to pull. CI, which does not have the file, passes `--secrets-file <path>` or
|
|
218
|
+
`--doc-token "<scope>=<value>"`
|
|
219
|
+
(scope = `workspace`, or the group's NAME).
|
|
220
|
+
⚠ OMISSION IS OPPOSITE PER SCOPE: off `workspaceConfig` the target's gate is left alone;
|
|
221
|
+
off an `apiGroup` it is CLEARED (an absent key there is written as the engine default). An
|
|
222
|
+
unsupplied gate refuses a `deploy` and fails the export for a publishing group.
|
|
223
|
+
- `xanosdk deploy --to workspace|tenant:<name>` skips the release flow to merge a LOCAL
|
|
224
|
+
build in, keeping the checks that need one (`--dry-run`, `--prune`, loss warnings) and
|
|
225
|
+
leaving nothing to roll back to. Prefer the release path.
|
|
226
|
+
⚠ Branches scope LOGIC: tables and microservices are shared by EVERY branch, so a
|
|
227
|
+
schema change reaches production on any branch — refused without
|
|
228
|
+
`--allow-shared-schema-changes` — and dropping a column destroys its data.
|
|
229
|
+
⚠ `--replace` CLEARS BY WORKSPACE, NOT BY BRANCH: every non-live branch goes, with the
|
|
230
|
+
saved versions that would restore one — unrecoverable. Refused without
|
|
231
|
+
`--allow-branch-deletion`; `--yes` does not imply it. The default merge leaves them alone.
|
|
232
|
+
- `xanosdk workspace reset-tables <entry> --table <guid>` puts NAMED tables back to their
|
|
233
|
+
compiled `table({ seed })` rows, touching no other table. Dry run unless `--write`.
|
|
234
|
+
⚠ ROWS BEFORE LOGIC: rows are workspace-shared like schema, so they reach production when
|
|
235
|
+
applied even while the logic reading them is staged — no ordering makes a half-data change
|
|
236
|
+
atomic. Reset rows FIRST, then `promote`: new rows under old logic break only a feature
|
|
237
|
+
being removed; new logic over old rows breaks the one you are shipping.
|
|
238
|
+
- `xanosdk pull` refreshes THIS project from a backend: rewrites decoded files,
|
|
239
|
+
keeps yours, lists and confirms (`--yes`).
|
|
240
|
+
- `xanosdk generate <source>`: the decoded tree ONLY, no project.
|
|
241
|
+
- `xanosdk status` names the env this project last deployed to, its URL and its expiry.
|
|
242
|
+
|
|
243
|
+
**Frontend wiring.** `--static <dir>` (or `xanosdk publish <dir>`) injects the env's backend URL as
|
|
244
|
+
`window.XANO_HOST` into EVERY html document in the build, before the app bundle runs,
|
|
245
|
+
so the frontend needs no rebuild to target an env. How to read it, local dev, verifying
|
|
246
|
+
the inject, and displaying a stored file: `llms/client.md`.
|
|
247
|
+
⚠ A static host serves its files verbatim, so everything injected is PUBLIC — base
|
|
248
|
+
URLs and publishable keys only, never secrets. Secrets go in backend env, read
|
|
249
|
+
server-side via `env(name)`.
|
|
250
|
+
⚠ `xanosdk preflight` uses the deploy credential unless `XANO_VALIDATE_INSTANCE` /
|
|
251
|
+
`XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) are set.
|
|
252
|
+
|
|
253
|
+
**Full CLI surface:** `xanosdk <command> --help` — every command, flag, default and accepted
|
|
254
|
+
backend; `manifest.json` carries the same in its `cli` array.
|
|
255
|
+
**Style:** reach statements through the `s` namespace (`s.db.add`, `s.math.add`, …).
|
|
256
|
+
The flat aliases (`dbAdd`, `setVar`, …) are identical in output; prefer `s.*`.
|
|
257
|
+
|
|
258
|
+
## Gotchas
|
|
259
|
+
|
|
260
|
+
Non-obvious authoring rules:
|
|
261
|
+
|
|
262
|
+
### Values and references
|
|
263
|
+
|
|
264
|
+
- **No callback builder.** Flat def-objects + `register*`, not
|
|
265
|
+
`workspace(w => w.table(...))`. `workspace(name)` returns a named `new Xano()`;
|
|
266
|
+
tables are `table({ schema: { col: f.text() } })`.
|
|
267
|
+
- **Reference-helper picker:** `ref` = stack var (`as:` output), `inp` = input,
|
|
268
|
+
`col` = table column (in `db.query` `where`), `auth("id")` = the caller,
|
|
269
|
+
`c.*` = a constant. Pick by what you're pointing at.
|
|
270
|
+
- **Foreign key is `f.tableRef(table)`, not `ref`.** `ref(name)` references a
|
|
271
|
+
stack variable (a value); `f.tableRef` is the column constructor.
|
|
272
|
+
- **Tagged values are DATA — a JS template literal cannot compose them.** `${ref(...)}`
|
|
273
|
+
(or `"a" + ref(...)`) stringifies the tag object at BUILD time: ``c.text(`Hi ${ref("u.name")}`)``
|
|
274
|
+
type-checks and encodes the literal text `Hi [object Object]`, served verbatim.
|
|
275
|
+
`export()` warns; `--strict` fails. Compose at RUNTIME:
|
|
276
|
+
`withFilters(c.text("Hi "), fl.concat(ref("u.name")))`, an `obj({...})`/record member,
|
|
277
|
+
or `c.expression('"Hi, " ~ $var.u.name')`.
|
|
278
|
+
- **Declare inputs with `input.<type>()`, read them with `inp("name")`.** `inp()`
|
|
279
|
+
resolves ONLY against this def's own `input` block — a value produced earlier in
|
|
280
|
+
the stack is `ref("var.field")`, not `inp("field")`. An undeclared name fails at
|
|
281
|
+
runtime with `Unable to locate input` (`export()` warns).
|
|
282
|
+
- **`get_input`/`get_raw_input` read the whole payload**, not one named input
|
|
283
|
+
(args are `{ as?, encoding?, excludeMiddleware? }` — no `name`). For a single
|
|
284
|
+
input use `inp("name")`.
|
|
285
|
+
- **To match every row, omit `where` — never pass a constant.** `where: c.bool(true)` is
|
|
286
|
+
not "no filter": a bare scalar literal states no condition and the engine reads the
|
|
287
|
+
resulting `context.search` as garbage, so it throws. An absent `where` is how "every
|
|
288
|
+
row" is spelled. A raw `Value` `where` stays the escape hatch for a clause built
|
|
289
|
+
elsewhere — `inp("clause")`, `ref("built_where")`, or `c.text("id > 0")`.
|
|
290
|
+
|
|
291
|
+
### Tables and columns
|
|
292
|
+
|
|
293
|
+
- **System columns are auto-injected.** `id` + `created_at` are prepended to
|
|
294
|
+
every table (`system: true` by default); declaring them by hand is redundant.
|
|
295
|
+
`id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.
|
|
296
|
+
Both are valid targets wherever a column name is accepted — `db.query` `sort`/
|
|
297
|
+
`output`, a `db.get`/`edit`/`del` `fieldName`, etc. (the column-name type is
|
|
298
|
+
`keyof schema | "id" | "created_at"`), and both appear in `InferRow<typeof table>`.
|
|
299
|
+
- **A column named `run` is reserved.** The table deploys and reads back fine, then
|
|
300
|
+
EVERY `s.db.add` into it 400s — at any column type, with or without a value — and
|
|
301
|
+
the error names the column while complaining about the VALUE. Use `run_id`. Exact,
|
|
302
|
+
case-sensitive, one name: `Run`/`runs`/`run_id` are fine. `--strict` fails on it.
|
|
303
|
+
- **An input named `run` is reserved when a call passes it**: the callee gets
|
|
304
|
+
defaults or a 400. HTTP binds it fine. `--strict` fails on it.
|
|
305
|
+
- **Numeric columns lose money quietly.** A normalized table's `f.decimal` reads
|
|
306
|
+
`0.12345678` back as `0.12346`; `f.int` saturates at `9223372036854775807`
|
|
307
|
+
instead of failing. Both at HTTP 200 and plausible on read, so it surfaces as
|
|
308
|
+
arithmetic that stops reconciling. Hold money as a count of the smallest unit;
|
|
309
|
+
exact bounds: `llms/fields.md`.
|
|
310
|
+
- **Self-referencing tables** need the bare-name form: inside `tweets`'s own
|
|
311
|
+
schema, write `f.tableRef("tweets", { type: "int" })` — the `const tweets`
|
|
312
|
+
handle isn't assigned yet, so the handle form throws "used before declaration".
|
|
313
|
+
- **Seed a table's starting rows with `table({ seed })`.** Rows typed against the
|
|
314
|
+
schema, inline (`seed: [{ name: "…" }]`) or from a file (`seedFile("./seed.json",
|
|
315
|
+
import.meta.url)`). Only `deploy` ships them, and a re-deploy re-seeds cleanly. Never
|
|
316
|
+
put secrets in `seed`. Row shape, `id` pinning, and the file rules: `llms/fields.md`.
|
|
317
|
+
- **`f.password()` defaults to `access: "internal"`, so `db.get` does NOT return it.**
|
|
318
|
+
A login stack that reads `ref("u.password")` after a plain `db.get` fails at runtime
|
|
319
|
+
with `Unable to locate var: u.password` — the column is simply absent from the row.
|
|
320
|
+
Name it in the read's `output` to pull it: `s.db.get({ table: users, fieldName: "email",
|
|
321
|
+
fieldValue: inp("email"), output: ["id", "email", "password"], as: "u" })`, then
|
|
322
|
+
`s.security.check_password`. `output` OVERRIDES column visibility — it is the only way to
|
|
323
|
+
read an `internal` column, and `export()` warns when a stack reads one a `db.get` did not
|
|
324
|
+
return.
|
|
325
|
+
- **Don't take a password through `input.password` on login — it double-hashes.**
|
|
326
|
+
An `f.password()` column hashes on write, and `input.password` *also* hashes the
|
|
327
|
+
submission on bind, so `s.security.check_password` compares two different hashes
|
|
328
|
+
and a correct password always fails (`ok:false` on a found row). Take the submitted
|
|
329
|
+
password as `input.text()` on both signup and login and pass the plaintext straight
|
|
330
|
+
to `check_password` (which does the comparison hash itself). `export()` warns.
|
|
331
|
+
|
|
332
|
+
### Reading and writing rows
|
|
333
|
+
|
|
334
|
+
- **DB reads are field-match, not `where`-expr.** `db.get`/`db.edit`/`db.del`/
|
|
335
|
+
`db.has`/`db.patch` match one field: `{ fieldName, fieldValue }` (`fieldName`
|
|
336
|
+
defaults to the PK `id`). Only `db.query` takes a `where`/`additionalWhere`
|
|
337
|
+
`expr(...)`; writes (`db.add`/`db.edit`/`db.add_or_edit`) take a `row`/`data`.
|
|
338
|
+
Signatures: `llms/statements-data.md`.
|
|
339
|
+
- **Single-field only — no composite match.** These ops match exactly ONE field;
|
|
340
|
+
there is no two-field form (the engine's by-field lookup takes a single
|
|
341
|
+
predicate). For a `(a, b)` existence/fetch — e.g. dedupe a `(habit, date)`
|
|
342
|
+
check-in — use `db.query({ where: [expr(col("habit"), "=", ...), expr(col("date"), "=", ...)], as })`
|
|
343
|
+
(a `where` array is ANDed) and branch on the result, rather than pushing the
|
|
344
|
+
check to the client.
|
|
345
|
+
- **Drilling into a maybe-null `db.get` result 500s — use `ref(path, { safe: true })`.**
|
|
346
|
+
`db.get` binds `null` on a no-match, but a nested `ref("owner.user_id")` raises a runtime
|
|
347
|
+
"Unable to locate var" (HTTP 500) when `owner` is null — an ownership guard throws instead
|
|
348
|
+
of failing cleanly. Two correct shapes:
|
|
349
|
+
- Guard existence first — `s.precondition({ expr: expr(ref("owner"), "!=", c.null()),
|
|
350
|
+
error_type: "notfound", error: c.text("Not found.") })` — then drill WITHOUT `safe`.
|
|
351
|
+
- Or drill null-safe inside an EXPRESSION or `obj()` operand:
|
|
352
|
+
`expr(ref("owner.user_id", { safe: true }), "=", auth("id"))` yields `null` (the guard
|
|
353
|
+
reads `false`) instead of 500ing. Works inside `obj({...})` too — no `s.set_var` hoist needed.
|
|
354
|
+
⚠ EXPRESSION and `obj()` operands ONLY — never a `db.*` match argument. `null` is not a
|
|
355
|
+
legal `fieldValue`/`id`, so a chained get (fetch the child row, then its parent to check
|
|
356
|
+
the owner) fails with HTTP 400 `Missing param: field_value` one statement BEFORE the guard.
|
|
357
|
+
There guarding existence first is MANDATORY. `export()` warns on a safe ref in that position.
|
|
358
|
+
⚠ That is for a base that EXISTS and may be null. If the base binds nothing at all (a typo),
|
|
359
|
+
`{ safe: true }` buries the bug as a silent `null` — `export()` warns with a did-you-mean;
|
|
360
|
+
fix the name instead.
|
|
361
|
+
- **`output` selects from what the statement BINDS, and a PAGED `db.query` binds the
|
|
362
|
+
envelope.** With `metadata: true` (the DEFAULT), the roots are `items.<column>` plus the
|
|
363
|
+
counters you want kept: `output: ["itemsReceived", "curPage", "items.id", "items.title"]`.
|
|
364
|
+
A bare column list there matches no envelope key, so ALL of them are dropped and the
|
|
365
|
+
endpoint answers `[]` at HTTP 200 with no error — and a `response` reading
|
|
366
|
+
`ref("rows.items")` then dies with `Unable to locate var: rows.items`. Prefix the columns,
|
|
367
|
+
or set `metadata: false` inside `paging` to select the rows directly.
|
|
368
|
+
`export()` warns; `--strict` fails.
|
|
369
|
+
|
|
370
|
+
### Stacks and calls
|
|
371
|
+
|
|
372
|
+
- **Block specials nest a `body`, not a `stack`.** `s.for`/`s.foreach`/
|
|
373
|
+
`s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`
|
|
374
|
+
take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);
|
|
375
|
+
`s.group(body)` and `s.util.post_process(body)` take it **positionally**.
|
|
376
|
+
`s.for` is **count-bounded** (`{ as, count: <Value>, body }`), not from/to. See the
|
|
377
|
+
authored signatures in `llms/statements-data.md`.
|
|
378
|
+
- **`s.api.call` / `s.task.call` / `s.trigger.call` / `s.workflow_test.call` are
|
|
379
|
+
WORKFLOW-TEST ONLY.** Outside a `workflowTest({...})` stack the engine cannot reach
|
|
380
|
+
the target, so one in a query/function/task deploys clean and then answers the first
|
|
381
|
+
real request with `ERROR_FATAL: <Type> does not exist: <type>:<n>` — and not per
|
|
382
|
+
host kind, the same call fails identically from a function a query runs.
|
|
383
|
+
`export()` refuses them. `s.function.call`, `s.tool.call`, `s.middleware.call` and
|
|
384
|
+
`s.addon.call` run anywhere, as does `s.function.run` (the ordinary function
|
|
385
|
+
invocation). To share logic between endpoints, put it in a `defineFunction` and
|
|
386
|
+
`s.function.run` it from both.
|
|
387
|
+
- **`expect.*` is a unit-test assertion; `s.expect.*` is a workflow-test statement.**
|
|
388
|
+
A statement's `mock` is keyed by TEST NAME; an undeclared name throws. See `llms/tests.md`.
|
|
389
|
+
- **A helper returning `Statement[]` widens the stack and kills `InferResponse`.**
|
|
390
|
+
Spreading `...requireX()` where the helper is typed `Statement[]` drops the stack's TUPLE
|
|
391
|
+
type, so EVERY `ref()`/`as` in that stack — even ones after the spread — resolves to
|
|
392
|
+
`unknown` and the response types as `StackTupleWidened`. Nothing fails at the helper; it
|
|
393
|
+
surfaces where the response is consumed, often a frontend typecheck. Fix: return
|
|
394
|
+
`statements(s.a(...), s.b(...))` — its tuple survives the spread. A helper that builds
|
|
395
|
+
its array in a LOOP cannot be a tuple; declare `responseShape` on the query there.
|
|
396
|
+
- **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**
|
|
397
|
+
The regex filters (`regex_test`/`regex_match`/`regex_replace`/…) are PHP `preg_*`: the
|
|
398
|
+
piped value is the PATTERN and must be delimiter-wrapped, and the ARGUMENT is the subject.
|
|
399
|
+
Correct: `withFilters(c.regex(/^[a-z-]+$/i), fl.regex_test(inp("slug")))`.
|
|
400
|
+
- A bare `c.text("^…$")` is an invalid pattern that matches *nothing* for every input,
|
|
401
|
+
so a precondition on it silently rejects all values. `c.regex(body, "i")` wraps +
|
|
402
|
+
escapes it for you (a JS `RegExp` too: `c.regex(/^…$/i)`).
|
|
403
|
+
- Reversed — subject piped, pattern in the argument — reads correctly, type-checks, and
|
|
404
|
+
answers false for EVERY input, so an `if (matches) reject` guard admits what it refuses.
|
|
405
|
+
- Build time refuses both: `withFilters` throws on a bare `c.text` pattern from ANY position
|
|
406
|
+
in the chain (a normalizer in front of the regex filter is refused too; nothing upstream
|
|
407
|
+
adds the delimiters) and on a pattern found in the subject slot; `s.expect.to_match` is the
|
|
408
|
+
same PATTERN slot, refused both ways; a `ref`/`inp` pattern is passed through untouched.
|
|
409
|
+
`export()` warns on a reversed pair in stored bytes.
|
|
410
|
+
- Better still: a native typed input (`input.email`) over hand-rolled validation.
|
|
411
|
+
- **An `auth()`-keyed limiter FAILS the request on any host with no caller identity —**
|
|
412
|
+
**at every tier.** `s.redis.ratelimit({ key: withFilters(c.text("rl:"), fl.concat(auth("id"))), error: c.text("…") })`
|
|
413
|
+
is the per-user form, and a public query, a task, or a function has no caller identity.
|
|
414
|
+
It does not degrade to one shared bucket: on a public endpoint the FIRST call 403s,
|
|
415
|
+
under `max`, and the host never runs. Key off `sys.remoteIp()` there. Attaching it
|
|
416
|
+
at `apiGroup({ middleware })` or the workspace tier inherits the same failure onto
|
|
417
|
+
every member endpoint. Export warns, naming the tier.
|
|
418
|
+
|
|
419
|
+
### Identity, build, and deploy
|
|
420
|
+
|
|
421
|
+
- **Same-name siblings collide — queries excepted: their identity carries group + verb.**
|
|
422
|
+
Guids derive from `(type, name)`, so two functions (or tables, toolsets, …) sharing
|
|
423
|
+
a name derive ONE guid and `export()` throws — give them DISTINCT names. A QUERY
|
|
424
|
+
derives from `(api group, verb, name)`, the engine's own uniqueness: `GET items` +
|
|
425
|
+
`POST items`, and `items` across two groups, coexist, and the lock keys the same
|
|
426
|
+
composed identity (`query:blog|GET|items`) — changing a query's verb or group
|
|
427
|
+
changes its identity. An explicit `guid` on a colliding pair clears the throw but
|
|
428
|
+
is NOT a lasting fix: a lock entry cannot hold two guids, so `export --lock`
|
|
429
|
+
refuses the pair (`export()` warns even unlocked); it is for pinning identity
|
|
430
|
+
across a rename, not for sharing a name.
|
|
431
|
+
- **`export()` vs `emitBundle()` vs `writeBundle()`:** `export()` returns the object,
|
|
432
|
+
`emitBundle()` the JSON, `writeBundle(app, path)` writes `xanosdk export`'s file. All
|
|
433
|
+
three run the SAME build-time checks, INCLUDING seed validation of a literal `seed: [...]`
|
|
434
|
+
array (row shape, unknown column, coercion, enum membership, the all-or-nothing `id`
|
|
435
|
+
rule). A DEFERRED seed — a thunk (`() => import("./seed.json")`) or `seedFile()` — is
|
|
436
|
+
checked only by the `xanosdk export`/`deploy` CLI path. Only `deploy` ships seed rows.
|
|
437
|
+
- Entry points: the `node:fs` writers (`writeBundle`/`writeArtifact`) and lock-file I/O
|
|
438
|
+
import from `@xano/sdk/node`, NOT the browser-safe `@xano/sdk` entry (a frontend
|
|
439
|
+
imports defs from it for `getPath()`/`InferInput`). `@xano/sdk/internal` is the
|
|
440
|
+
compiler machinery, never needed to author. READING a bundle back is `@xano/sdk/bundle`
|
|
441
|
+
— a statement walker (`2.if.0` paths), a structural hash, `mvp:*` catalog, `tableRefOf`.
|
|
442
|
+
- **Intra-workspace imports use `.js` specifiers** (`../tables/links.js`), not
|
|
443
|
+
extensionless — the defs compile under `moduleResolution: bundler`. Add the `.js`.
|
|
444
|
+
- **Agents authenticate with env vars — never `xanosdk login`.** `login` blocks on a
|
|
445
|
+
browser consent no agent can complete. Set all three of `$XANO_INSTANCE_URL`,
|
|
446
|
+
`$XANO_WORKSPACE_ID`, `$XANO_META_TOKEN` and run `deploy`/`promote` directly: no disk, no
|
|
447
|
+
rotation, so it survives repeated runs.
|
|
448
|
+
- The triple outranks every other credential (`--config`, `$XANO_PROFILE` and
|
|
449
|
+
`$XANO_REFRESH_TOKEN` included, each named on stderr when displaced). Setting SOME of the
|
|
450
|
+
three is a hard error, so a misspelled secret cannot fall through to whatever credential
|
|
451
|
+
the runner has. An explicit `--profile` against it is a hard error too, not a displacement.
|
|
452
|
+
- On disk: `xanosdk profile add <name> --instance … --workspace-id <n>` reads the token
|
|
453
|
+
from piped stdin, else PROMPTS (a flag would reach the process list); the instance must accept it.
|
|
454
|
+
`auth.json` holds a map of named profiles, selected by `--profile <name>`. The older
|
|
455
|
+
`$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works but ROTATES: single-use.
|
|
456
|
+
- **Event-driven objects fire on an EPHEMERAL.** A `task` (scheduled), an `mcpServer`,
|
|
457
|
+
and every trigger — `tableTrigger` included — run normally on an ephemeral env, which
|
|
458
|
+
is where `deploy` sends them. So test an event-driven design (screen-on-insert, cron
|
|
459
|
+
cleanup, MCP tool call) by deploying it and letting it run.
|
|
460
|
+
|
|
461
|
+
## Statements
|
|
462
|
+
|
|
463
|
+
Reachable through the `s` namespace: `s.<path>({...})`. Declarative statements
|
|
464
|
+
take one typed args object (field names match the engine); every surface's field
|
|
465
|
+
signature is in `llms/statements-catalog.md`. Specials (`[special]`) are
|
|
466
|
+
hand-authored: control flow is below, and the rest are in the `llms/statements-*`
|
|
467
|
+
topic files this doc's navigation names.
|
|
468
|
+
Wrap an `input`-routed `value` field in `ignored(...)` to store it but SKIP it at
|
|
469
|
+
runtime — the engine records `<name>:ignore` and the parameter falls back to its
|
|
470
|
+
default. Not the same as an empty value, and not the same as omitting the field
|
|
471
|
+
(which stores no entry at all). Mostly seen on a pulled workspace. A field written
|
|
472
|
+
into `context` instead (the `s.math.*`/`s.text.append` mutation family) has no slot
|
|
473
|
+
for the flag and THROWS rather than dropping it.
|
|
474
|
+
Fields marked `value` take a `Value` (`c.*`/`ref`/`inp`); `comparison` takes an
|
|
475
|
+
`expr(...)`. A `→ as: <type>` suffix names what the statement's `as:` output var
|
|
476
|
+
holds (curated, not exhaustive — absence means read the `[output]` flag and prose).
|
|
477
|
+
|
|
478
|
+
### Specials — authored signatures
|
|
479
|
+
|
|
480
|
+
Control flow & blocks (each nests a sub-stack; block specials name it `body`):
|
|
481
|
+
|
|
482
|
+
- `s.set_var(name, value)` · `s.update_var(name, value)` · `s.return(value)` · `s.comment(text)` — positional.
|
|
483
|
+
- **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 })`).
|
|
484
|
+
- **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`.
|
|
485
|
+
- `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[]`.
|
|
486
|
+
- `s.for({ as, count, body })` — **count-bounded** loop (`as` is the index), NOT from/to; `count` is a `Value`, not a bare number.
|
|
487
|
+
- `s.foreach({ as, list, body })` — iterate `list`; `as` is the current item.
|
|
488
|
+
- `s.while({ when, body })` — `when` is a condition (`expr`/`cmp`/`and`/`or`).
|
|
489
|
+
- `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.
|
|
490
|
+
- `s.try_catch({ try, catch?, finally? })` — three `Statement[]` blocks.
|
|
491
|
+
- `s.group(body)` / `s.util.post_process(body)` — take a `Statement[]` **positionally**.
|
|
492
|
+
- `s.foreach_break()` / `s.foreach_continue()` / `s.foreach_remove()` — nullary loop control.
|
|
493
|
+
- `s.expect.to_throw({ body, exception? })` — `body` is the statements expected to raise; `exception` is a `Value`, not a bare string.
|
|
494
|
+
|
|
495
|
+
# Object kinds
|
|
496
|
+
|
|
497
|
+
> Read when deciding WHAT to build — every authorable primitive, what each is, and which factory + register method builds it. Also when `register*` will not typecheck an array built by `.flatMap()`/`.concat()`.
|
|
498
|
+
|
|
499
|
+
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.
|
|
500
|
+
|
|
501
|
+
⚠ 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.
|
|
502
|
+
|
|
503
|
+
- function: `defineFunction` → `Xano.registerFunctions` → payload `function` — Reusable server-side logic (a custom function) callable from any stack via `s.function.run`.
|
|
504
|
+
- table: `table` → `Xano.registerTables` → payload `dbo` — A database table: typed columns (`f.*`), indexes, and views; the schema other kinds read and write.
|
|
505
|
+
- query: `query` → `Xano.registerQueries` → payload `query` — An HTTP API endpoint (verb + path) bound to an API group; the main request/response surface.
|
|
506
|
+
- api_group: `apiGroup` → `Xano.registerApiGroups` → payload `app` — A container that groups queries under a shared base path, CORS, and swagger config.
|
|
507
|
+
- 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.
|
|
508
|
+
- 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.
|
|
509
|
+
- 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.
|
|
510
|
+
- trigger (toolset): `mcpServerTrigger` → `Xano.registerTriggers` → payload `trigger` — Fires when an MCP client connects to a bound MCP server; filter its tools/prompts/resources via `t`. Response-bearing.
|
|
511
|
+
- 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.
|
|
512
|
+
- 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.
|
|
513
|
+
- 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.
|
|
514
|
+
- tool: `tool` → `Xano.registerTools` → payload `tool` — An agent/MCP tool: a callable capability with typed inputs an AI agent can invoke.
|
|
515
|
+
- prompt: `prompt` → `Xano.registerPrompts` → payload `prompt` — An MCP prompt: typed arguments plus a stack that builds the messages an MCP client receives. Exposed by an `mcpServer`.
|
|
516
|
+
- resource: `resource` → `Xano.registerResources` → payload `resource` — An MCP resource: contents an MCP client reads by URI (a literal or a `{variable}` template), produced by a stack. Exposed by an `mcpServer`.
|
|
517
|
+
- mcp_server: `mcpServer` → `Xano.registerMcpServers` → payload `toolset` — An MCP server exposing tools, prompts and resources to external MCP clients.
|
|
518
|
+
- 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`.
|
|
519
|
+
- task: `task` → `Xano.registerTasks` → payload `task` — A scheduled background job (cron/interval) that runs a stack on a timer.
|
|
520
|
+
- 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 CLONES it before every run — see `llms/kinds-core.md`.
|
|
521
|
+
- 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.
|
|
522
|
+
- addon: `addon` → `Xano.registerAddons` → payload `addon` — A reusable read fragment that enriches a query result by joining related table data.
|
|
523
|
+
- 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>`).
|
|
524
|
+
- 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.
|
|
525
|
+
- 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.
|
|
526
|
+
- microservice: `microservice` → `Xano.registerMicroservices` → payload `microservice` — A container workload deployed alongside the workspace, called from a stack with `s.microservice.request`. Def shapes (`builtin` containers vs a `helm` chart) and the SECRET-handling rules its config carries: `llms/statements-calls.md`.
|
|
527
|
+
- knowledge: `knowledge` → `Xano.registerKnowledge` → payload `knowledge` — Markdown Xano's own AI (the builder agent, and Meta API / MCP readers) reads before it acts — never an `agent()` run — an `agents.md` of standing instructions, a `skill`, or a `doc`. The body is a real markdown FILE via `knowledgeFile("./x.md", import.meta.url)`, and `mode` decides how much of it reaches the agent.
|
|
528
|
+
- workspace: `workspaceConfig` → `Xano.registerWorkspace` → payload `workspace` — Workspace-level configuration such as default middleware chains and request-history defaults per host kind.
|
|
529
|
+
|
|
530
|
+
# Core def shapes
|
|
531
|
+
|
|
532
|
+
> Read when authoring a function, query, api group, task, workflow test, middleware, tool, or addon — and for the `response` and `expr` shapes every one of them uses.
|
|
533
|
+
|
|
534
|
+
The def-object passed to each factory. `?` = optional. `input` is keyed by
|
|
535
|
+
input name (`input.<type>(opts?)`); `stack` is `Statement[]` (`s.*`); `response`
|
|
536
|
+
is a `ResponseDef` (see **Responses** below). Object identity is `guid?` —
|
|
537
|
+
omit it and it derives from `name` (for a query, from group + verb + name; set it
|
|
538
|
+
to survive a rename).
|
|
539
|
+
|
|
540
|
+
- `defineFunction({ name, guid?, description?, docs?, workspace?, input?, stack?, response?, tests? })`
|
|
541
|
+
- `query({ name, verb, apiGroup?, guid?, auth?, input?, stack?, response?, responseType?, apiEnabled?, disabled?, cache?, description?, docs?, tests?, example? })` — a `cache` setting turns caching on unless `active: false`
|
|
542
|
+
- `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.`
|
|
543
|
+
- `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.
|
|
544
|
+
- `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.
|
|
545
|
+
- `name` is the endpoint PATH within the group.
|
|
546
|
+
- 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.
|
|
547
|
+
- 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.
|
|
548
|
+
- 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).
|
|
549
|
+
- An input a `GET`/`DELETE`/`HEAD` looks ONE ROW up by (`s.db.get`/by-field edit/patch/delete; not `has` or an email/token/code) belongs in the path — `export()` warns `query.path-segment-candidate`: `getPath()` types STATIC. A segment is any value naming WHICH resource is wanted (`"shop/{country}"`); one that NARROWS A LIST (`s.db.query`) stays a query param. Intended? `diagnostics: { allow: [code] }` on the query (any def, for its own advisories) accepts it under `--strict`.
|
|
550
|
+
- Routes are matched FIRST-FIT in creation order, and a literal gets NO precedence over a `{param}`: `"runs/{id}"` (a text `id`) created before `"runs/trend"` answers `GET /runs/trend` itself. Two routes in one group and verb that one request path can match are REFUSED at `export()` (`query.route-shadowed`) — reordering `registerQueries` cannot fix it, since a release keeps an existing route first. Make the paths disjoint (`"runs/by-id/{id}"`), or declare a numeric param `input.int({ required: true })` (or `decimal`): only a REQUIRED int/decimal segment matches digits only — an OPTIONAL one matches any text, so `input.int()` still takes `trend`.
|
|
551
|
+
- Inputs absent from the path are ordinary query-string/body params.
|
|
552
|
+
- 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.
|
|
553
|
+
- **Client recipe:** `q.getPath({ params: { slug: "hello" } })` → `/api:<canonical>/blog/hello` — never interpolate by hand.
|
|
554
|
+
- `getPath` percent-encodes each value (so `?`/`#`/spaces stay in their segment) and throws on what encoding cannot contain: a `/`, and a segment that becomes `.`/`..` as sent (a URL parser drops those before routing, addressing a different endpoint).
|
|
555
|
+
- The keys are typed from the literal `name`, so a typo is a compile error.
|
|
556
|
+
- `q.toSearchParams(input)` drops GET path params (free `query.toSearchParams` keeps them); lists send `k[]=a&k[]=b` (`k=a&k=b` binds only `b`).
|
|
557
|
+
- Browser bundle cost, the `routes.gen.ts` alternative, and spot-checking a def from Node: `llms/client.md`.
|
|
558
|
+
- `apiGroup({ name, guid?, canonical?, description?, docs?, swagger?, apiGroupEnabled?, documentation?, cors?, middleware? })` — a query container; queries bind to it via their `apiGroup`. `canonical` holds only letters, digits, `_` and `-` (it is the `<canonical>` of `/api:<canonical>`); anything else THROWS at build.
|
|
559
|
+
- `documentation?`: `{ require_token? }` — the gate on this group's hosted docs. `require_token: true` IS the declaration; the token lives in `xano/.secrets.json` under this group's guid, and a literal `token` FAILS the export. ⚠ ALWAYS emitted, present or not: an absent key is written as the engine default on import, so omitting the block CLEARS an existing gate (the opposite of the `workspaceConfig` rule). `swagger: true` publishes the docs; `require_token` gates them only alongside a non-empty token, so `swagger: true` with no gate makes the whole group readable by anyone with the URL — export warns once per build and `--strict` fails; `diagnostics: { allow: ["api-group.docs-public"] }` accepts it. A declared gate with NO token drops the block, so those bytes clear it: export FAILS under `swagger: true`, and with `swagger` off warns and completes.
|
|
560
|
+
- `cors?`: `{ mode?, allowOrigins?: string[], allowHeaders?: string[], allowCredentials?, maxAge?, allowMethods?: { get?, post?, put?, patch?, delete?, head? } }`.
|
|
561
|
+
- `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.
|
|
562
|
+
- ⚠ 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 browser call fails while export, deploy and the preflight 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; a non-origin entry (trailing `/`, path) is refused.
|
|
563
|
+
- Tasks, workflow tests, middleware and tools below (agents and MCP servers: `llms/kinds-agent-mcp.md`) share the envelope conventions (`guid?`, `description?`, `docs?`, `tags?`, `history?`) unless noted.
|
|
564
|
+
- `task({ name, guid?, description?, docs?, datasource?, active?, tags?, history?, schedule?, stack?, middleware? })` — a scheduled background job (function-like `stack`, no `input`/`response`). `active?` defaults `true`; `active: false` deploys it parked.
|
|
565
|
+
- `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** (omitted ⇒ runs ONCE) — spell it `every("15m")`, a compile-time duration-to-seconds helper (`s`/`m`/`h`/`d`/`w`, terms concatenate). No cron, no timezone — a fixed-offset `startsOn` drifts an hour at each DST change; for a local time run hourly, gated on the local hour `withFilters(c.now(), fl.epochms_date("G", "America/New_York"))`. `repeatEnabled?`/`endsEnabled?` follow whether `freq`/`endsOn` is set and are recovery-only — state one to reproduce a stored gate left OFF. Fires on an ephemeral (see Gotchas).
|
|
566
|
+
- `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> })`.
|
|
567
|
+
- `datasource?`: 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, which is why `"live"` warns at compile time. On an EPHEMERAL, `"live"` holds only the seed fixtures (plus rows a `--keep-data` deploy kept), so it is the way to read `table({ seed })` rows — a small clone, dropped after the run. ⚠ The value is STORED, so clear it before you promote or the same test clones the real database.
|
|
568
|
+
- `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 whose `vars` DIFFERS BY PHASE: `pre` → the request inputs (`payload.vars.<field>`); `post` → `{ status, result }`, the host's outcome (`payload.vars.result.<field>`; plus `payload` on an error). A request field read in `post` 500s after the host already ran (`export()` warns); read the request there with `s.util.get_raw_input`. `s.middleware.call` is the one path that DOES bind the declared map.
|
|
569
|
+
- `resultStrategy?`: `"merge" | "replace"` (default `merge`) — how the middleware `response` folds into the accumulator of the phase it is ATTACHED to, and the two phases accumulate DIFFERENT things. In `post` the accumulator is the host's RESULT, so a returned object changes what the CALLER receives. In `pre` it is the host's REQUEST INPUTS, so a returned object changes what the HOST receives. `merge` folds key-by-key, `replace` substitutes wholesale; either way the next entry in the chain sees the updated value. So `pre` + `replace` DISCARDS every caller input the middleware does not re-emit, and a discarded input is then unreadable — a 500, even where it is declared `required: false`, because input defaulting has already happened by the time the override lands.
|
|
570
|
+
- `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.
|
|
571
|
+
- `tool({ name, guid?, description?, instructions?, docs?, enabled?, title?, annotations?, icons?, output?, 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`. `title`/`annotations`/`icons`/`output` are MCP metadata (`llms/kinds-agent-mcp.md`).
|
|
572
|
+
- 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([...])`.
|
|
573
|
+
- `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.
|
|
574
|
+
- `tableAlias` is its SQL alias (`context.dbo.as`), qualifying `where`/`sort` columns (`col("merchant.id")`).
|
|
575
|
+
- `where`/`sort` take the same surface as `s.db.query`; `where` encodes `context.search`, `sort` encodes `context.return.list.sort` (`return.single.sort` with `cardinality: "single"`) and is refused with `count`/`exists`. `where` is the predicate binding the addon to the parent row — `expr(col("id"), "=", inp("user_id"))`.
|
|
576
|
+
- `cardinality` shapes the result (`context.return.type`, omitted for the `"list"` default). Rarer context (`eval`/`bind`/`lock`) stays raw `context` passthrough.
|
|
577
|
+
- End to end — define, register, attach:
|
|
578
|
+
```ts
|
|
579
|
+
const author = addon({ name: "author", table: users, input: { user_id: input.int() }, where: expr(col("id"), "=", inp("user_id")), output: ["id", "display_name"], cardinality: "single" });
|
|
580
|
+
workspace("forum").registerAddons([author]); // plus the tables/queries
|
|
581
|
+
s.db.query({ table: threads, addon: [{ addon: author, as: "_author", input: { user_id: out("author_id") } }], as: "rows" }) // rows[]._author: { id, display_name }
|
|
582
|
+
```
|
|
583
|
+
### Responses
|
|
584
|
+
|
|
585
|
+
The `response?` field (on functions, queries, tools, middleware, and
|
|
586
|
+
response-bearing triggers) maps to the stored `result[]`:
|
|
587
|
+
|
|
588
|
+
- `ResponseDef = Value | Record<string, Value>`.
|
|
589
|
+
- A single `Value` → one unnamed result item: `response: ref("rows")`.
|
|
590
|
+
- A record → one named item per key: `response: { user: ref("u"), token: ref("t") }`.
|
|
591
|
+
- Omitted → empty `result[]` (no response body).
|
|
592
|
+
|
|
593
|
+
### Expressions (`expr`)
|
|
594
|
+
|
|
595
|
+
`expr(left, op, right)` builds the comparison used by every condition/`where`
|
|
596
|
+
surface — `s.conditional`/`s.while` `when` (incl. each `elif` branch), and
|
|
597
|
+
`db.query` `where`/`additionalWhere` (and the search triggers) — one shared tree.
|
|
598
|
+
|
|
599
|
+
- `op`: `=`, `!=`, `>`, `<`, `>=`, `<=` (JS aliases `==` `===` `!==` are accepted and normalized).
|
|
600
|
+
- `left`/`right` are `Value`s — `col("x")` (a table column, `db.*` statements only), `ref`, `inp`, `auth(...)`, or `c.*`.
|
|
601
|
+
- For the full operator set (`in`/`like`/`ilike`/`between`/`contains`/`overlaps`/`@>`/`~`/`search`/…)
|
|
602
|
+
use `cmp(left, op, right, { ignoreEmpty? })`; compose nested boolean logic with `and(...)`/`or(...)`.
|
|
603
|
+
- ⚠ The wider `cmp` operators are DATABASE-only (`where`, table view filter, db trigger
|
|
604
|
+
`search`). A RUNTIME condition — `s.conditional`/`elif`, `s.while`, `s.precondition`,
|
|
605
|
+
`array.*` `if` — takes the `expr` set only; the rest are refused at build time because
|
|
606
|
+
deployed they fail the request with `Invalid op: <op>` on that branch, usually a guard.
|
|
607
|
+
`cond.*` builds the filter-then-compare form that DOES run there, and handles the operand
|
|
608
|
+
direction (the engine's `in` filter pipes the ARRAY, the reverse of `contains`): `cond.in`,
|
|
609
|
+
`notIn`, `contains`/`icontains`/`notContains`, `startsWith`/`endsWith` (+ `i` forms),
|
|
610
|
+
`empty`/`notEmpty`, `isNull`/`notNull`, `between(v, lo, hi)`, `has(obj, path)`, `count(v, n)`.
|
|
611
|
+
Each returns a `Comparison`, usable anywhere `expr(...)` is.
|
|
612
|
+
- A condition/`where` accepts a single `expr(...)`/`cmp(...)`, an `and()`/`or()` group, an array of
|
|
613
|
+
those (ANDed), or (for `where`) a raw `Value`. `s.conditional`/`s.while`/`s.switch`, `db.query`,
|
|
614
|
+
`precondition`, and the `array.*` predicates all take the same TREE shape (operators per above).
|
|
615
|
+
- ⚠ `mixed(a, { or: b }, { and: c })` reproduces a container whose terms do NOT all join the
|
|
616
|
+
same way — pulled workspaces contain it. **Do not author it** (export warns `condition.mixed`). The
|
|
617
|
+
stored form does not record the grouping, and the two places it can appear disagree: a
|
|
618
|
+
branch (`s.conditional`/`s.while`/`precondition`) folds terms strictly left to right, so
|
|
619
|
+
`a OR b AND c` is `(a OR b) AND c`, while a `db.query` filter applies the engine's
|
|
620
|
+
AND-before-OR precedence and selects `a OR (b AND c)`. Write `and(or(a, b), c)` or
|
|
621
|
+
`or(a, and(b, c))` — each says one reading in every context. Pulls report these as
|
|
622
|
+
`ambiguous-condition`.
|
|
623
|
+
- A **filtered** operand (`withFilters(...)`) works inline in any condition/`where` (conditional,
|
|
624
|
+
while, `db.query`/addon, …) — e.g. `cmp(withFilters(col("title"), fl.trim()), "=", inp("q"))`.
|
|
625
|
+
- **Compose a rule set as SIBLINGS, not a folded chain.** `and(...rules)` takes any
|
|
626
|
+
number of terms and encodes flat; `rules.reduce((acc, r) => and(acc, r))` nests one
|
|
627
|
+
container per rule, which costs quadratic bytes (512 terms: 394 KiB flat, 21 MiB
|
|
628
|
+
folded) and is refused past 128 levels. Mixed joins: `and(or(...anyOf), ...allOf)`.
|
|
629
|
+
- e.g. `db.query({ table: posts, where: expr(col("author"), "=", auth("id")), as: "rows" })`.
|
|
630
|
+
|
|
631
|
+
# Consuming defs from a client
|
|
632
|
+
|
|
633
|
+
> Read when a frontend imports a def (`getPath()`/`InferResponse`) or calls the deployed backend — what the import costs, `window.XANO_HOST`, local dev, file URLs, and a Node spot-check.
|
|
634
|
+
|
|
635
|
+
Importing a def into a browser bundle or a Node script — for `getPath()`, `getUrl()`, `verb`, or `InferResponse` — runs its factory calls; these are the costs and the checks.
|
|
636
|
+
|
|
637
|
+
- **Client bundle size / tree-shaking.** `@xano/sdk` is `sideEffects: false` and pulls
|
|
638
|
+
no Node built-ins, so a bundler drops unused SDK exports. But importing a **def** for its
|
|
639
|
+
`getPath()`/`verb`/`getUrl()`/`getChannel()` also pulls whatever its `stack` references:
|
|
640
|
+
the `s.*`/`c.*` factory CALLS run at module load to BUILD it. Types are free.
|
|
641
|
+
⚠ A FLOOR — **~267 kB minified (~65 kB gzipped)** for the FIRST def; splitting modules
|
|
642
|
+
never removes it. The floor is the RUNTIME, not the def: a second or much richer def
|
|
643
|
+
adds ~2 kB, so trimming a def does not shrink it.
|
|
644
|
+
Fix: `xanosdk routes <entry> --emit xano/routes.gen.ts` (`paths` is an accepted alias) — verbs, paths, and sockets as
|
|
645
|
+
plain data importing NOTHING, still compile-checked: `routePath("GET blog/{slug}", { slug })`
|
|
646
|
+
`channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
|
|
647
|
+
URLs lifted to `wss://h/ws/<tenant>:<canonical>`). A rename is a type error, not a 404.
|
|
648
|
+
- **Verifying a def outside a bundler.** Inside a bundler (Vite/webpack) importing a
|
|
649
|
+
query def to read `getPath()`/`verb` works directly. To spot-check from Node, run a REAL
|
|
650
|
+
file with `tsx <file.ts>` **from inside the project root** — not `tsx -e "import …"`
|
|
651
|
+
(its CJS-preparse mis-resolves the package `exports` map → ERR_PACKAGE_PATH_NOT_EXPORTED),
|
|
652
|
+
and not bare `node file.ts` (chokes on the `.js`-specifier intra-workspace imports the
|
|
653
|
+
xanosdk CLI's own loader resolves). Running from outside the project root also breaks
|
|
654
|
+
the `@xano/sdk` specifier resolution.
|
|
655
|
+
|
|
656
|
+
**Reading the injected backend URL.** `xanosdk deploy <entry> --static <dir>` writes the deployed env's URL
|
|
657
|
+
into every html document: a prerendered build serves a different one per
|
|
658
|
+
route, and a route without the global renders fine while every call goes to the wrong
|
|
659
|
+
origin. Read it at runtime with a build-time fallback:
|
|
660
|
+
const HOST = (typeof window !== 'undefined' && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
|
|
661
|
+
The scaffold's `lib/api.ts` types it (`interface Window { XANO_HOST?: string }` — `undefined` in dev)
|
|
662
|
+
and exports this as a `string`, `XANO_HOST`; outside a scaffold declare the global yourself.
|
|
663
|
+
In LOCAL DEV there is no injected global, so the fallback is what answers: set
|
|
664
|
+
`VITE_XANO_HOST` in a `.env.local` beside `.env.example` at the PROJECT ROOT. The
|
|
665
|
+
scaffold's vite config sets `envDir` there (its `root` is `frontend/`, and Vite
|
|
666
|
+
resolves `.env` files against `root`) — without it the var reads as undefined, the
|
|
667
|
+
host falls back to '', and every call 404s off the dev server.
|
|
668
|
+
⚠ It is INJECTED in bracket form — `window["XANO_HOST"]="…"` — so verifying a deploy
|
|
669
|
+
by grepping `window.XANO_HOST` matches nothing and reads as a failed inject. Grep the
|
|
670
|
+
bare `XANO_HOST` token.
|
|
671
|
+
**Frontend without the backend.** `xanosdk publish <dir> [--to <backend>]` uploads an
|
|
672
|
+
already-built directory and nothing else — no compile, no import (scaffold script
|
|
673
|
+
`npm run xano:deploy:frontend`). `publish` does not reach a local engine: after a local-engine
|
|
674
|
+
deploy, pass `--to ephemeral`, or give the engine the frontend with
|
|
675
|
+
`xanosdk deploy <entry> --local-engine --static <dir>` (served at `http://<prefix>.localhost:<port>`).
|
|
676
|
+
**Server-rendered SvelteKit.** `adapter()` from `@xano/sdk/sveltekit` (needs `esbuild`) writes
|
|
677
|
+
the static half at the build's root plus a server half in `.xano-ssr/`. A local engine renders every
|
|
678
|
+
path that is not a file through it (`load` reads the engine's own URL as `XANO_HOST` from
|
|
679
|
+
`$env/dynamic/private`; a rendered page gets the deploy's `window.XANO_HOST` too). Any other host
|
|
680
|
+
is not sent `.xano-ssr/`: it serves the static half and answers other paths with the `404.html`
|
|
681
|
+
shell, where a route with a server `load` fails (no `__data.json`). A scaffolded project configures
|
|
682
|
+
SvelteKit in `vite.config.ts`'s `sveltekit({ adapter })`, where `svelte.config.js` is ignored.
|
|
683
|
+
⚠ `.xano-ssr/server.js` inlines `$env/static/private` values; it is never served (`/.xano-ssr/` answers 404).
|
|
684
|
+
The injected `XANO_HOST` is the DESTINATION's URL, so one build published to two
|
|
685
|
+
destinations serves two different documents. `--release <name>` checks only that the
|
|
686
|
+
release exists; `--branch <label>` (workspace only) refuses unless that label is LIVE —
|
|
687
|
+
a label match, not a content comparison. `--json` reports `published` and `verified` separately.
|
|
688
|
+
**Displaying a stored file.** A file column comes back as `{ path, name, type, size,
|
|
689
|
+
meta, access, url }`. ⚠ Do NOT use its `url`: on a tenant-scoped environment that field
|
|
690
|
+
addresses the instance host WITHOUT the `/tenant/<name>` segment and 404s, silently —
|
|
691
|
+
as a broken `<img>`, with every API assertion still passing. Build the URL from `path`
|
|
692
|
+
instead: `fileUrl(row.avatar, HOST)` returns `null` for
|
|
693
|
+
an absent file and is correct on an ephemeral and an instance workspace alike.
|
|
694
|
+
|
|
695
|
+
# Toolchain modules
|
|
696
|
+
|
|
697
|
+
> Read when `package.json` has a `"xanosdk"` block or you run `xanosdk marketplace`.
|
|
698
|
+
|
|
699
|
+
**Toolchain modules** extend the CLI, not the workspace — nothing to register in
|
|
700
|
+
`xano/index.ts`. An installed one runs on `export` and `deploy` (after the compile,
|
|
701
|
+
before the upload) and is verified, never rewritten, under `--frozen-lock`. Config
|
|
702
|
+
lives in this project's `package.json` `"xanosdk"` block, keyed by package name.
|
|
703
|
+
⚠ `marketplace install <pkg>` is not just `npm install`: it asks the module its questions
|
|
704
|
+
and writes that block plus the `.gitattributes` lines it contributes. A module that
|
|
705
|
+
arrived any other way (a plain `npm install`, a merged PR) is configured by running that
|
|
706
|
+
same command on it; until then `export`/`deploy` report it unconfigured and it runs on
|
|
707
|
+
shipped defaults. `marketplace reinstall <pkg>` re-asks with the current settings as the
|
|
708
|
+
defaults and re-enables a disabled one; `marketplace remove <pkg>` drops block, lines and
|
|
709
|
+
dependency together — a plain `npm uninstall` leaves the block, and a package it names
|
|
710
|
+
that is no longer installed fails every `--frozen-lock` run.
|
|
711
|
+
|
|
712
|
+
# Error index
|
|
713
|
+
|
|
714
|
+
> Read when a request, test, build, or pull fails with a message you did not write — the string, its cause, and where the fix is.
|
|
715
|
+
|
|
716
|
+
### Request-time (deployed) failures
|
|
717
|
+
|
|
718
|
+
- `Unable to locate input: <name>` — `inp()` names an undeclared input, or an attached middleware reads its own `input` (never bound). Declare it; a stack value is `ref("var.field")`; middleware reads the body with `s.util.get_all_input`. `llms.txt`; `llms/kinds-core.md`.
|
|
719
|
+
- `Unable to locate var: <a.b>` (HTTP 500) — a dotted `ref` into a null base (a `db.get` miss), or a column absent from the row (`f.password` is `internal`). Guard existence first, or use the null-safe `safe` option; name the column in `output`. In a `post` middleware, `<var>.vars` from `s.util.get_all_input` holds `{ status, result }` (plus `payload` on an error), not the request. `llms.txt`; `llms/kinds-core.md`.
|
|
720
|
+
- `Missing var entry: <name>` inside `s.expect.to_throw` — the body runs in an isolated var stack; bind what it needs inside the body. `llms/tests.md`.
|
|
721
|
+
- `Missing param: field_value` (HTTP 400) — a `db.*` match argument resolved to `null` (a safe ref, a nullable foreign key). Guard existence before the lookup; an optional FK stores `0` and is read with field-match `db.get`. `llms.txt`; `llms/fields.md`.
|
|
722
|
+
- `Value is less than the minimum value of 1` (HTTP 400) — a `db.get` given `0` in an editor-authored workspace (SDK field-match `db.get` answers 200 + null). `llms/statements-data.md`.
|
|
723
|
+
- `Invalid op: <op>` — a `cmp` operator (`in`, `like`, …) in a RUNTIME condition, which takes the `expr` set only; write `or(expr(...), expr(...))`. `llms/kinds-core.md`.
|
|
724
|
+
- `Unsupported param format` / `Unsupported parameter reference` — an aggregate or `eval` `name` that is not a bare column, or a bare `eval` name for a joined column. `llms/statements-data.md`.
|
|
725
|
+
- `ParseError: Invalid value for param` (HTTP 400) — `contains`/`@>`/`overlaps` on a text column (use `includes`), or a joined column qualified without `tableAlias`. `llms/statements-data.md`.
|
|
726
|
+
- `ERROR_FATAL: <Type> does not exist: <type>:<n>` — `s.api.call`/`s.task.call`/`s.trigger.call`/`s.workflow_test.call` outside a workflow test; share logic via `defineFunction` + `s.function.run`. `llms.txt`.
|
|
727
|
+
- `ERROR_FATAL "Unable to decode."` — a populated JSON string where the engine expects `c.obj`'s form; write `c.obj({...})` or `obj({...})`. `llms/values.md`.
|
|
728
|
+
- `Precondition failed.` in place of your message — `error` was a bare string; pass `c.text("…")`. `llms/fields.md`.
|
|
729
|
+
- `Param: token - Text filter requires an integer, float, string or boolean value` — `s.api.call` `auth.token` given a tagged `Value`; it must be a bare string. `llms/statements-calls.md`.
|
|
730
|
+
- Every `s.db.add` into one table 400s naming a column while complaining about its VALUE — the column is named `run`. `llms.txt`.
|
|
731
|
+
- HTTP 200 with an EMPTY body where `0` was expected — a bare `returnType: "count"` of zero; wrap it as `{ count: ref("n") }`. `llms/statements-data.md`.
|
|
732
|
+
- HTTP 200 with `[]` from a `db.query` that matches rows — a paged query (`metadata: true`, the default) whose `output` names bare columns; the selection applies to the ENVELOPE, so prefix them (`items.<column>`) or set `metadata: false` inside `paging`. `llms.txt`; `llms/statements-data.md`.
|
|
733
|
+
- Every field mapped off an `s.api.request` result reads `null` — the upstream answered an error (a 403 for a missing `User-Agent` is the common one) and its body arrived as ordinary `response.result`; send a `User-Agent` and gate on `response.status`. `llms/statements-calls.md`.
|
|
734
|
+
- A lambda returns error TEXT with HTTP 200 (`Could not resolve "node:crypto"`, an undefined-binding message) — the body threw; use the preloaded globals, the surface's bindings, and `capture`. `llms/lambda.md`.
|
|
735
|
+
- `Unable to locate request.` (404) on an endpoint that deployed — a lowercase verb, a `.` in the name, or a CORS `mode` typo that dropped the whole API group. `llms/kinds-core.md`.
|
|
736
|
+
- A browser call fails on a missing `access-control-allow-origin` while deploy looks fine — CORS `mode: "custom"` with `"*"` or an unmatched origin. `llms/kinds-core.md`.
|
|
737
|
+
- A websocket is closed right after the handshake — a refused `connect` gate: an empty/falsy return, a crash, or a gating trigger with no `response`. `llms/triggers.md`.
|
|
738
|
+
- `to_throw failed - response is ok` around an auth-refused call — `ERROR_CODE_ACCESS_DENIED` carries no message; the auth gate is not reachable from a workflow test. `llms/tests.md`.
|
|
739
|
+
- Output reads `Hi [object Object]` — a tagged value inside a JS template literal; compose at runtime with `withFilters` + `fl.concat`. `llms.txt`.
|
|
740
|
+
|
|
741
|
+
### Build-time and tooling failures
|
|
742
|
+
|
|
743
|
+
- `export --strict` fails on a warning whose shape is meant (a fixture pinning a hazard) — accept it on ITS def only with `diagnostics: { allow: ["<code>"] }`, which takes any warning code that def raised (never an error) on every def kind. Accepted warnings are still listed: `export({ accepted: [] })`, `--json` `accepted[]`. An allowed code the def no longer raises warns `diagnostics.allow-unused`.
|
|
744
|
+
- `must be ES modules` — package.json says `"type": "commonjs"`; set `"type": "module"`. `llms.txt` Quickstart.
|
|
745
|
+
- A response types as `StackTupleWidened` — a `Statement[]` helper was spread into the stack; return `statements(...)`. `llms.txt`; `llms/statements-runtime.md`.
|
|
746
|
+
- `ERR_PACKAGE_PATH_NOT_EXPORTED` — `tsx -e "import …"`, or running from outside the project root; run a real file with `tsx <file.ts>` from the root. `llms/client.md`.
|
|
747
|
+
- `Missing statement: mvp:placeholder` on import — an unconfigured slot in a pulled tree; replace it with the statement it stands in for. `llms/legacy.md`.
|
|
748
|
+
- A CLI command fails under `--json` or piped (unless stdout carries data): stdout is `{ ok: false, error: { code, message, exitCode, details?, suggestion? } }`, as does a failed release write. Codes: `SDK_SEED_IN_STATIC` (`details.leaks[]` → `publicSeed`), `SDK_EXPORT_INVALID` (`details.diagnostics[]`, `--strict` too), `SDK_DRIFT`, `SDK_BRANCH_TAKEN`, `SDK_BRANCH_LIVE`, `SDK_RELEASE_NAME_TAKEN`, `SDK_RELEASE_FILES_NOT_CARRIED`, `SDK_LIVE_BRANCH_MISMATCH` (`details.conflictsWith`), `SDK_IDENTITY_CONFLICT`, `SDK_KIND_CONFLICT`, `SDK_PRUNE_OUT_OF_SCOPE`, `SDK_IMPORT_REFUSED`, `SDK_SHARED_SCHEMA_CHANGE`, `SDK_CONSTRAINT_VIOLATION`, `SDK_CREDENTIAL_REJECTED` (`details`: `{ profile, credentialType, instance, workspaceId, signIn }`; ids null on a refused env refresh), `SDK_USAGE` (`details.reason: "needs-confirmation"`: run its `Re-run as`), `SDK_ERROR`. Landing refusal `details.refused`: identityConflict, renamePending, kindConflict, uniqueViolation, tableTrigger, importInProgress, importRefused, pruneOutOfScope. `warnings[]` / `details.warnings` / `accepted[]` entries: `{ code, message, subject? }` (`subject`: the def's `{ kind, name }`). Backend documents add `selector` and `workspaceId`.
|
|
749
|
+
- A CLI command reports `could not reach <url>: fetch failed (<code>)` — blocked egress, not a bad credential; fix it before `xanosdk login`, which rotates a single-use refresh token.
|
|
750
|
+
|
|
751
|
+
### CLI exit codes
|
|
752
|
+
|
|
753
|
+
A failure document's `exitCode` matches.
|
|
754
|
+
|
|
755
|
+
| Code | Meaning | Next step |
|
|
756
|
+
|---|---|---|
|
|
757
|
+
| 0 | Success, and a `delete` of a missing name (`alreadyGone`). | — |
|
|
758
|
+
| 1 | A usage error (a missing local file included), a refused request, or anything not listed. | Read the message. |
|
|
759
|
+
| 2 | Ran and disagreed: `preflight` failed, `workspace diff` differed, `promote`, a release or an import did not land as sent or hit a conflict, the `init --from`/`pull` round-trip check failed, a lock or branch-label conflict, or `init` could not install its dependencies or an add-on (scaffolded anyway). | Fix what it reports. |
|
|
760
|
+
| 3 | `deploy --static`: the backend landed, the static site did not. | Deploy the site again. |
|
|
761
|
+
| 4 | `deploy --require-microservices`: a microservice was not ready in time. The backend landed. | Check the microservice. |
|
|
762
|
+
| 5 | `test` / `deploy --test`: the suite ran and a test failed. A deploy is not retracted. | Fix the test or the code. |
|
|
763
|
+
| 6 | `test`, `deploy --test`: the suite could not be reached. | Check the backend; rerun. |
|
|
764
|
+
| 7 | `upgrade --check`: an upgrade is available. | `xanosdk upgrade`. |
|
|
765
|
+
| 8 | A named backend could not be addressed (or a named release, profile, branch, test, module, lock entry, `--seed` guid or local-engine version is missing): gone, expired, unreachable, a stopped local engine, or a busy import. | Retry, or redeploy. |
|
|
766
|
+
| 9 | A write whose outcome is unknown, Ctrl-C during a write included. | Read the target's state before retrying. |
|
|
767
|
+
| 130 | Ctrl-C outside a write or install. | — |
|
|
768
|
+
|
|
769
|
+
# Saved unit tests, assertions, and mocks
|
|
770
|
+
|
|
771
|
+
> Read when authoring a `workflowTest()` stack, when a query/function/middleware carries `tests`, when a statement needs a `mock`, or when running a deployed environment's tests.
|
|
772
|
+
|
|
773
|
+
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.
|
|
774
|
+
|
|
775
|
+
- `tests?: TestDef[]` on `query()`, `defineFunction()`, and `middleware()`. No other kind stores them.
|
|
776
|
+
- `{ name, id?, description?, datasource?, input?, expect?, token? }`.
|
|
777
|
+
- `name` is unique within the object and is the key a statement's `mock` resolves against; a duplicate throws.
|
|
778
|
+
- `id` defaults to a derivation from the owning object plus the name. A pulled test carries the id Xano minted, emitted explicitly.
|
|
779
|
+
- `datasource` defaults to `""` — an EMPTY datasource. Any other value names one the engine CLONES before the run; `"live"` warns `test.live-datasource` on the owning def.
|
|
780
|
+
- ⚠ That empty default means **no `table({ seed })` rows exist while a unit test runs**, exactly as for a `workflowTest()` — every `db` read misses, so `resp("0.id")` fails though the deployed endpoint returns those rows. **To read `table({ seed })` rows on an EPHEMERAL**, set `datasource: "live"`: there it holds only the seed fixtures (plus rows `--keep-data` kept); the run's writes are dropped. `diagnostics.allow` accepts its warning. ⚠ `datasource` is STORED, not a per-run flag — a test left on `"live"` clones the REAL database once it runs against an instance, so clear it before you promote. Otherwise create the rows INSIDE the run (a `defineFunction` fixture the stack calls first) or `mock` the read.
|
|
781
|
+
- `input` is `{ <input name>: Value }` — tagged values (`c.*`, `ref`, …), never plain JS scalars.
|
|
782
|
+
- `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.
|
|
783
|
+
|
|
784
|
+
### Assertions: `expect.*`, not `s.expect.*`
|
|
785
|
+
|
|
786
|
+
- `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.
|
|
787
|
+
- Subject first — argument order is the assertion: `expect.to_equal(resp(), c.int(2))`.
|
|
788
|
+
- `resp()` is the response under test; `resp("id")` drills, `resp("a-b")` takes the bracket escape.
|
|
789
|
+
- 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`.
|
|
790
|
+
- 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`).
|
|
791
|
+
- `expect.to_be_within(subject, min, max)` — both bounds EXCLUSIVE; a subject equal to either fails.
|
|
792
|
+
- `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. `exception` is a `Value` — `expect.to_throw(c.text("Wrong password"))`, not a bare string (a bare one fails to compile with TS2345).
|
|
793
|
+
|
|
794
|
+
### Mocks
|
|
795
|
+
|
|
796
|
+
A statement returns a value instead of doing its work, for one named test.
|
|
797
|
+
|
|
798
|
+
- `s.set_var("x", c.int(1), { mock: { "adds one": c.int(123) } })`. Every statement takes `mock`.
|
|
799
|
+
- 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.
|
|
800
|
+
- `{ value, enabled: false }` keeps a mock stored but switched off.
|
|
801
|
+
- A mock applies ONLY while its test runs. It changes nothing about a normal request.
|
|
802
|
+
|
|
803
|
+
### `example`
|
|
804
|
+
|
|
805
|
+
`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.
|
|
806
|
+
|
|
807
|
+
### What a `workflowTest()` run actually sees
|
|
808
|
+
|
|
809
|
+
The run is isolated in ways that make a correct test fail for reasons the failure message does not name.
|
|
810
|
+
|
|
811
|
+
- The run uses an EMPTY datasource by default (`datasource: ""`), so **no `table({ seed })` rows exist while it runs** (same as a unit test, above): a read of seeded row 1 fails with its own precondition message, not an empty-database error. Build fixtures INSIDE the test, or set `datasource: "live"` under the ephemeral-only rule above.
|
|
812
|
+
- `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.
|
|
813
|
+
- `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. An outer one raises `Missing var entry: <name>` there, which the test reports as `to_throw` not matching (`export()` warns). `exception` is a `Value` whose text the raised message must CONTAIN (`c.text("already exists")`, not a bare string); omit it to accept any error.
|
|
814
|
+
- `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.
|
|
815
|
+
- 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.
|
|
816
|
+
- 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.
|
|
817
|
+
|
|
818
|
+
### Running them
|
|
819
|
+
|
|
820
|
+
`xanosdk 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.
|
|
821
|
+
|
|
822
|
+
- `--on <backend>` picks the backend — `--on ephemeral:<name>`, `--on local-engine`, `--on tenant:<name>`, `--on workspace`; omitted, the one this project last deployed to (grammar: **Backends** in `llms.txt`). ⚠ `workspace` runs against the REAL database: safe only while every test keeps `datasource: ""`, since a stored non-empty datasource is CLONED before the run.
|
|
823
|
+
- `xanosdk test list` shows what is there without running it; `test run "<name>"` runs one; an ambiguous name prints qualified forms (`function:math/happy path`, `workflow:<name>`) `run` accepts.
|
|
824
|
+
- `--kind unit|workflow` narrows to one family. `--concurrency <n>` defaults to 1: tests share the environment database.
|
|
825
|
+
- A failing suite exits 5; exit 6 when the suite could not be reached — retry it; a backend that can't be looked up exits 8 (see errors). Having no tests is success.
|
|
826
|
+
- For CI: the exit code says THAT something failed, the JSON says WHICH. Progress goes to stderr; stdout carries one JSON document when not a terminal, or with `--json`. `run-all` and `run`: `{ kind, env, name, display, total, passed, failed, tests: [{ kind, name, qualified, object?, status: "pass"|"fail", message?, expectations?: [{ index, status, message? }], timing? }] }` (`message`: the first failure; `expectations`: a unit test's every expectation in order, `index` from 0; `env`: an ephemeral's or tenant's name; `name`: the backend's own, a local engine's too; null if none), same keys on an empty suite. `list` is `{ kind, env, name, display, total, tests }`; `deploy --test` nests the run under `testRun`.
|
|
827
|
+
- `xanosdk 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.
|
|
828
|
+
|
|
829
|
+
# Agent and MCP def shapes
|
|
830
|
+
|
|
831
|
+
> Read when defining an `agent()`, `mcpServer()`, `prompt()` or `resource()`.
|
|
832
|
+
|
|
833
|
+
- `mcpServer({ name, guid?, description?, instructions?, docs?, enabled?, canonical?, spec?, tags?, history?, tools?, prompts?, resources?, oauth?, llm?, output? })` — an MCP toolset. `llm?`/`output?` are the same blocks `agent()` takes and are usually absent: an MCP server and an agent are ONE stored object distinguished by `type`. Returns a handle with `getPath()`/`getUrl(baseUrl)` for the Streamable-HTTP endpoint — `getUrl` is NOT idempotent, a `baseUrl` already carrying an `/x2/mcp/<…>/stream` path THROWS, so resolve ONCE from the instance base URL. Fires on an ephemeral. A TENANT rides AFTER the prefix (`/x2/mcp/tenant/<tenant>/<canonical>/<token>/stream`), not as a base-URL segment: `getUrl` LIFTS a `/tenant/<name>` out of the base and re-places it there, so `getUrl(window.XANO_HOST)` is correct as-is; concatenating them by hand builds an unregistered route that 404s. Pass `{ tenant }` when the base URL cannot name it (a tenant on its own domain).
|
|
834
|
+
- `tools?`: a `ToolsetToolEntry[]`. Pass the `tool()` HANDLES directly (`tools: [saveNote]`); use the `{ tool, enabled?, auth?, type?, resourceUri?, toolMeta? }` wrapper when an entry needs more. `type: "resource"` (+ a required `resourceUri`) is the LEGACY way to publish a tool as an MCP resource — author a `resource()` and list it in `resources` instead; `toolMeta` is extra metadata on a tool entry. The two payload fields are per-type and supplying the other one is refused, because the engine stores it against neither. `auth` names an auth **table** (a `table({ auth: true })` handle or its name) — per-tool, and the only gate there is. ⚠ It is also what gives the tool a CALLER, on an `agent()` exactly as on an MCP server. `auth()` inside a tool whose entry names no table does NOT return null: the call answers a normal result carrying `{"code":"ERROR_FATAL","message":""}`, so the model reads a successful tool result and reports success while nothing is written. A caller token does not rescue it — the ENTRY supplies identity. Spell it `{ tool: saveNote, auth: users }`; `export()` warns. An entry naming no tool THROWS at export; a deliberate raw `id: 0` warns and carries through.
|
|
835
|
+
- `prompts?` / `resources?`: `prompt()` / `resource()` HANDLES (or names), or a `{ prompt, enabled?, auth? }` / `{ resource, enabled?, auth? }` wrapper. `auth` names the auth table and is what gives the stack a caller: a prompt/resource reading `auth()` behind an entry with none answers every `prompts/get` / `resources/read` with `-32603 "Access Denied"` (`export()` warns `toolset.primitive-reads-auth-ungated`). Listing one target twice THROWS; two resources with one `uri` on one server fail `export()` (`mcp.resource-uri-duplicate`). MCP-server only — `agent()` takes tools only.
|
|
836
|
+
- `history?` is the server tier for its tools, prompts AND resources alike; an object's own `history` wins. The workspace tier (`workspaceConfig({ history })`) has `prompt` and `resource` keys, both default on.
|
|
837
|
+
- `oauth?`: per-server sign-in; omit and nothing changes. Union on `mode`; `authTable` (REQUIRED, auth `table()` handle or name) is whose row the signed-in user is — `auth()` on the whole server, `t.auth` in its trigger. Hosted (the platform is the authorization server): `{ mode: "hosted", authTable, loginUrl }` — clients are sent to `loginUrl` (YOUR page, which is also the consent screen) with `mcp_request`; show `s.mcp.oauth.request`, sign the user in, then your endpoint calls `s.mcp.oauth.complete` with their `decision` and the page redirects to the URL it binds. External (your IdP issues tokens): `{ mode: "external", authTable, issuer, column, audience?, audienceAck?, claim?, claimAck?, preset?, allowedClientIds? }` — token `claim` (default `sub`) matched against `column`; `preset` is `auth0|clerk|workos|entra|okta|cognito|generic`. `loginUrl`/`issuer`/`audience` take `env("NAME")` (resolved per environment).
|
|
838
|
+
- `export()` FAILS: hosted without `loginUrl` (`mcp.oauth-hosted-incomplete`); external without `issuer`/`column` (`mcp.oauth-external-incomplete`); `preset: "clerk"` without `allowedClientIds` (`mcp.oauth-preset-client-ids`); `claim` other than `sub`/`oid`/`email` without `claimAck` (`mcp.oauth-claim-unacked` — users can edit it at most providers). Unknown `mode`/`preset` THROWS; so do the dropped `consent`/`branding`/`trustedClients`.
|
|
839
|
+
- `prompt({ name, guid?, description?, docs?, title?, icons?, tags?, history?, input?, stack?, response?, responseShape?, middleware? })` — an MCP prompt; takes `registerPrompts`. `input` is the prompt's arguments (each field's `description`/`required`/enum `values` reach the client; enum values also answer completion requests). `response`: a text value is ONE user message; a list of `{ role: "user" | "assistant", content }` is sent in order: `s.set_var("m", obj({ list: [obj({ role: c.text("user"), content: ref("body") })] }))`, `response: ref("m.list")`. Without `response` or a top-level `s.return`, `prompts/get` fails.
|
|
840
|
+
- `resource({ name, uri, guid?, description?, docs?, mimeType?, title?, icons?, annotations?, tags?, history?, input?, stack?, response?, responseShape?, middleware? })` — an MCP resource; takes `registerResources`. Clients address it by `uri`, never `name`.
|
|
841
|
+
- `uri`: a literal (`"docs://readme"`, no `input`) or an RFC 6570 LEVEL-1 template (`"orders://{region}/{order_id}"`). It must start with a scheme; each `{variable}` is letters/digits/`_`, one per brace pair, never two adjacent. `input` must be EXACTLY the template's variables, each a single `text`/`int`/`decimal`/`bool`/`enum`/`email`/`uuid` — anything else THROWS. A value that does not convert is refused before the stack runs.
|
|
842
|
+
- `response`: a string is returned as text; non-UTF-8 content (a file, binary) as a base64 blob. `mimeType?` omitted → derived from what the stack returns.
|
|
843
|
+
- `annotations?`: `{ audience?: ("user" | "assistant")[], priority?: 0..1, lastModified?: ISO 8601 string }`.
|
|
844
|
+
- MCP metadata, on `tool`/`prompt`/`resource`: `title?` (display name clients show instead of `name`); `icons?: { src, mimeType?, sizes?, theme? }[]` — `src` is `https:` (≤ 2048 chars), a base64 `data:` URI of PNG/JPEG/WebP (≤ 64 KiB), or `hostedFile("./icon.png", import.meta.url)`, a repo file served at each backend's own absolute URL (a pull keeps the `/vault/…` path); SVG, `http:` and every other scheme THROW; `mimeType` is `image/png|jpeg|webp`, `sizes` a list (`["48x48"]`, `["any"]`), `theme` `light|dark`. Tool only: `annotations?: { readOnlyHint?, destructiveHint?, idempotentHint?, openWorldHint? }` — hints; an unset one is derived by the platform — and `output?`, a structured-output schema in the `input` grammar: a returned plain object then reaches the client as `structuredContent` plus a text copy, and a result that does not match is a tool error naming the field.
|
|
845
|
+
- `s.mcp.elicit({ key, message, input?, as? })` — ask the MCP client's user mid-call; binds `{ action: "accept" | "decline" | "cancel", content? }` (`content` only on accept, keyed by the `input` field names). `key`: a stable name unique in the stack; `message`: string or value. `input` is the form: a FLAT set of `text`/`email`/`date`/`int`/`decimal`/`bool`/`enum` fields (an enum may be a list); nested objects, files, JSON, timestamps, `password` and `sensitive` fields THROW. Works in tool, prompt and resource stacks.
|
|
846
|
+
- ⚠ The WHOLE stack re-runs from the top when the user answers — everything BEFORE an elicit runs once per round trip, so ASK FIRST, then act. A write before an elicit (`s.db.*` writes, raw SQL, a non-GET `s.api.request`, `s.util.send_email`, `s.function.run`/`.call`, `s.tool.call`) makes `export()` warn `mcp.write-before-elicit`; accept a deliberate one per def with `diagnostics: { allow: ["mcp.write-before-elicit"] }`. An elicit inside `s.db.transaction` commits the transaction's earlier writes on every round trip.
|
|
847
|
+
- Input is collected only from clients on MCP `2026-07-28` or later that declare form elicitation. Every other caller — an older client, a debug run, an agent, a workflow test — gets `{ action: "cancel" }` and the stack carries on, so ALWAYS handle `cancel`. A runtime error inside an async function run.
|
|
848
|
+
- `s.mcp.progress({ progress, total?, message? })` — report progress to a client that asked for it (a no-op otherwise); values never go backwards (a lower `progress` is sent as the last one).
|
|
849
|
+
- `s.mcp.oauth.complete({ request, decision, unsafe_user_id?, as? })` — run by the endpoint your `loginUrl` page calls after sign-in; `request` is the page's `mcp_request`. Signs in the CALLER (`auth()`, an `authTable` row whose session token is under 10 minutes old) and binds the single-use URL (text) to redirect to. `decision` (`"approve" | "deny"`, REQUIRED) is what the user chose on your page. ⚠ `unsafe_user_id` names the row directly, skipping the caller checks — anyone reaching that endpoint signs in as that user.
|
|
850
|
+
- `s.mcp.oauth.request({ request, as? })` — a pending sign-in's verified details for your `loginUrl` page to show before the user approves: `{ client_name, client_logo, callback_host, client_verified, server_name }`. Does not consume it.
|
|
851
|
+
- `s.mcp.oauth.revoke({ mcp_server, user_id? | grant_id? | all?, as? })` — end hosted sign-ins on a server: EXACTLY ONE of `user_id`, `grant_id`, `all: c.bool(true)`, else `export()` fails (`mcp.oauth-revoke-target`). Binds `{ revoked }`.
|
|
852
|
+
- `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 })`.
|
|
853
|
+
- `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.
|
|
854
|
+
- `tools?`: same `ToolsetToolEntry[]` as `mcpServer` — bare `tool()` handles, or a `{ tool, enabled?, auth? }` wrapper when one needs `enabled`/`auth`. A tool reading `auth()` MUST use the wrapper naming the auth table — a bare handle runs it with no caller (see `mcpServer`'s `tools?`).
|
|
855
|
+
- `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.
|
|
856
|
+
- **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("summary", 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 }}`. `.toolCalls` is the FINAL step's calls only (often `[]`); every call is a `.steps[].content[]` entry of `type: "tool-call"`.
|
|
857
|
+
- **Marketplace:** for chat history, use `@xano-sdk/chatbot`; for vector RAG, use `@xano-sdk/vector` (`tools: [vector.searchTool]`). Find add-ons with `xanosdk marketplace search <q>`.
|
|
858
|
+
- Both kinds persist under the `toolset` payload key, so a same-name `agent` + `mcpServer` pair derives ONE guid and `export()` throws — give them distinct names.
|
|
859
|
+
|
|
860
|
+
# Knowledge def shape
|
|
861
|
+
|
|
862
|
+
> Read when the workspace defines a `knowledge()` item for its AI agents.
|
|
863
|
+
|
|
864
|
+
- `knowledge({ name, description?, type?, mode?, enabled?, body, refs?, guid?, tags? })` — markdown Xano's own AI reads before it acts — the workspace's builder agent, and outside agents reading the workspace through the Meta API or MCP. It does NOT reach an `agent()` run by `s.ai.agent.run`, whatever its `type` or `mode`: put what such an agent must know in its `llm.systemPrompt`. Takes `registerKnowledge`.
|
|
865
|
+
- `body` (REQUIRED): `knowledgeFile("./runbook.md", import.meta.url)` — a path to a real markdown FILE. There is no inline-string form. The path resolves relative to the MODULE THAT DECLARES the item, not the process working directory and not the workspace entry; `import.meta.url` is required and is what makes that true.
|
|
866
|
+
- `refs?`: `knowledgeDir("./runbook", import.meta.url)` — a directory whose WHOLE tree ships, recursively, paths preserved: UTF-8 text only (else refused; binary `.DS_Store` skipped), symlinks not followed. Keep the body OUT of it, or it ships as a reference file.
|
|
867
|
+
- `type?`: `"skill"` (default) | `"doc"` | `"agents.md"`. `skill` and `doc` differ in how they are filed, not in what an agent receives.
|
|
868
|
+
- `mode?`: `"auto"` (default) | `"always"` | `"referenced"`.
|
|
869
|
+
- `enabled?` defaults `true`. A disabled item is stored and never reaches an agent.
|
|
870
|
+
|
|
871
|
+
### What reaches the agent
|
|
872
|
+
|
|
873
|
+
- `type: "agents.md"` — full body on EVERY turn, whatever `mode` says. At most ONE per workspace; a second is an export error. Setting `mode` on one warns, because nothing reads it.
|
|
874
|
+
- `mode: "always"` — full body on every turn.
|
|
875
|
+
- `mode: "referenced"` — full body only on turns whose message names the item (bare name or `@name`).
|
|
876
|
+
- `mode: "auto"` — name and `description` only; the agent loads the body when a request matches.
|
|
877
|
+
- Reference files are never injected wholesale — an agent searches them on demand. That is what makes a large `mode: "auto"` item cheap.
|
|
878
|
+
- ⚠ `mode: "always"` on a long body spends its whole length on every request, and nothing in the types says so. `"auto"` is the default for that reason. For an `auto` item `description` is ALL the agent sees until it decides to load the body, so write it to be matched against a request rather than as a title.
|
|
879
|
+
- `name` takes letters, digits, and `/ _ - { } . ` or a space; anything else is an export error.
|
|
880
|
+
|
|
881
|
+
# Realtime def shapes
|
|
882
|
+
|
|
883
|
+
> Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.
|
|
884
|
+
|
|
885
|
+
- **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).
|
|
886
|
+
- `realtimeServer({ name, guid?, description?, enabled?, canonical?, tags?, history? , middleware? })` — the container.
|
|
887
|
+
- `enabled` defaults to **false** — the one `enabled` in the SDK that does.
|
|
888
|
+
- An enabled server with no active channel still refuses the handshake.
|
|
889
|
+
- `realtimeChannel({ name, server, guid?, description?, active?, input?, anonymousClients?, presence?, publish?, conversation?, delivery?, rateLimit?, tags?, history?, middleware? })`
|
|
890
|
+
- `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 `{}`.
|
|
891
|
+
- 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). `realtimeChannel()` refuses it and a partial-segment `{param}`; `getChannel()` a value outside `[A-Za-z0-9_-]`.
|
|
892
|
+
- An INACTIVE channel reports the same error as a nonexistent one — deactivating leaks nothing.
|
|
893
|
+
- `middleware?: { pre?, post? }` is the CONTAINER tier, on the channel and on the server alike — the default chain every message inherits, the same shape an api group has. Full chain: message -> channel -> server -> branch/workspace, mirroring `history`. Put a realtime auth or rate-limit guard here rather than repeating it on every `realtimeMessage`.
|
|
894
|
+
- `anonymousClients` also gates CONNECT: a tokenless socket connects only if an active channel of its server sets it (no server flag); each channel admits its own join.
|
|
895
|
+
- `publish?: { who?: "nobody"|"anyone"|"authenticated", direct? }` — `who` defaults to `nobody`: nobody can publish until you set it. `direct` (default false) allows DIRECT messages — a frame whose `options.to = { dbo_id, row_id }` addresses one USER (`dbo_id` = the auth table's id as `s.realtime.get_session` reports it, `row_id` = the user's row id) and reaches every socket of theirs on the channel, no one else; the sender gets a receipt `{ delivered_local, direct: true }`. `direct` REPLACES `who` for such a frame rather than adding to it (`direct: true` with `who: "nobody"` delivers direct messages and refuses broadcasts); an anonymous sender is refused. A direct message is never stored in the transcript or the `at_least_once` stream, so an offline user never gets it. ⚠ An `options.to` naming no usable identity is sent as an ordinary broadcast to the whole channel where `who` admits the sender.
|
|
896
|
+
- `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.
|
|
897
|
+
- `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.
|
|
898
|
+
- `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.
|
|
899
|
+
- `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).
|
|
900
|
+
- `input` types the message PAYLOAD. `server` is required only when `channel` is a bare path.
|
|
901
|
+
- `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.
|
|
902
|
+
- Only `"channel"`/`"others"` fan out AND are RECORDED in the `conversation` transcript — a `"sender"` response is invisible to every future joiner.
|
|
903
|
+
- **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.
|
|
904
|
+
- 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.
|
|
905
|
+
- `s.realtime.get_session({ as })` — the CALLER's realtime session for the current frame. FLAT shape:
|
|
906
|
+
- `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.
|
|
907
|
+
- Works in a realtime MESSAGE stack and in CHANNEL and SERVER trigger stacks; off that path it degrades to an anonymous session.
|
|
908
|
+
- For a path param prefer `inp("room_id")` in a MESSAGE; a lifecycle TRIGGER has only the session. Reach for the session when you need the CONNECTION (identity/extras) — "who is this sender" on an anonymous-client channel.
|
|
909
|
+
- ⚠ 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.
|
|
910
|
+
- `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.
|
|
911
|
+
- `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.
|
|
912
|
+
- 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.
|
|
913
|
+
- DELIVERY-ONLY — fanned out as-is; does NOT invoke a `realtimeMessage()` handler even when `message` names one (a channel `deliver` trigger still runs).
|
|
914
|
+
- SERVER-AUTHORITATIVE — bypasses `publish.who`, which governs CLIENTS. Authorize in your own stack.
|
|
915
|
+
- ⚠ 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.
|
|
916
|
+
- `authTable`/`authId` are ASSERTED attribution on the frame — not a credential, nothing validates them.
|
|
917
|
+
- **Client recipe (derive, never hardcode):**
|
|
918
|
+
- `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 `xanosdk export <entry> --lock`.
|
|
919
|
+
- Auth is a bearer token passed as the websocket SUBPROTOCOL: `new WebSocket(url, token)`. No token = an anonymous client, admitted only where `anonymousClients: true`.
|
|
920
|
+
- Frames are JSON `{ action: "join"|"leave"|"broadcast"|"ack"|"ping"|"presence", channel, type?, payload?, options?, id? }`. `broadcast` REQUIRES `type`: a `realtimeMessage` of that channel, else refused `Unknown message type`. 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.
|
|
921
|
+
- `options` is `{ to?, client_id?, channel? }` — `to: { dbo_id, row_id }` makes the frame a direct message (needs `publish.direct`), `client_id` is the at-least-once cursor handle, and `options.channel` WINS over a top-level `channel`.
|
|
922
|
+
- ⚠ 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.
|
|
923
|
+
- 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`.
|
|
924
|
+
- ⚠ `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.
|
|
925
|
+
- `error` carries `payload.message`, plus `code`/`limit`/`retry_after` when rate limited. `rate_limited` is the ONLY code — do NOT switch on `code`.
|
|
926
|
+
- 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.
|
|
927
|
+
- **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.
|
|
928
|
+
- Socket: `server.getUrl(base, { tenant })` → `/ws/<tenant>:<canonical>`. ⚠ A bare canonical on a tenant host resolves against the INSTANCE workspace instead.
|
|
929
|
+
- 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.
|
|
930
|
+
- Because the shapes differ, `getUrl` TRANSLATES a tenant base URL instead of concatenating: pass the `https://<host>/tenant/<name>` that `xanosdk status` prints (and that deploy injects as `window.XANO_HOST`) and the tenant is LIFTED into the socket form. So `getUrl(window.XANO_HOST)` needs no `{ tenant }`, and a CONFLICTING `{ tenant }` alongside it throws.
|
|
931
|
+
- ⚠ `getUrl`/`socketUrl` are NOT idempotent — a `baseUrl` that already carries a `/ws/<…>` path THROWS. Resolve ONCE from the http(s) base; never feed a result back in.
|
|
932
|
+
- 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.
|
|
933
|
+
- ⚠ 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.
|
|
934
|
+
- **Presence frames** (a `presence: true` channel only):
|
|
935
|
+
- `presence_full` carries `payload.members`, an ARRAY: at join it EXCLUDES the receiver (first joiner: `[]`), on a re-request it includes it. `presence_join`/`presence_leave` carry a single `payload.member`.
|
|
936
|
+
- A member is `{ id, dbo_id, authenticated, extras, joined_at }`: `id` the auth row id as a string (`"0"` anonymous), `dbo_id` the auth table's id (`0` anonymous), `extras` the connection's extras (`[]` if none), `joined_at` epoch SECONDS.
|
|
937
|
+
- Render `presence_full`, then apply deltas. One member per authenticated identity (a second tab fires no `presence_join`); anonymous ones all have `id` `"0"`, never collapsed.
|
|
938
|
+
- Join order: `join` ack → `presence_full` → (others get `presence_join`) → conversation replay → `replay` frames.
|
|
939
|
+
- A joined client re-requests it with `{ action: "presence", channel }`, answered to the SENDER only; a socket that never joined is REFUSED.
|
|
940
|
+
- **Conversation frames — the transcript hydrates the client, so DO NOT build a hydration endpoint.**
|
|
941
|
+
- 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`.
|
|
942
|
+
- Render `message` frames identically either way: no fetch or table read is needed to paint the initial view.
|
|
943
|
+
- ⚠ `{ enabled: true }` alone is a no-op: `limit` defaults to 0 and 0 means RETAIN NONE (see `conversation` above) — always pass `limit`.
|
|
944
|
+
- 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.
|
|
945
|
+
- The transcript is a capped ring (`limit`, `ttl`), not storage: persist to a table only for durability, search, or reads beyond it.
|
|
946
|
+
- **`delivery.guarantee: "at_least_once"` is a CLIENT CONTRACT, not just a channel setting.**
|
|
947
|
+
- The client must ACK what it receives — `{ action: "ack", channel, id }`, confirmed by `{ action: "ack", channel, payload: { cursor } }`.
|
|
948
|
+
- ⚠ 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`.
|
|
949
|
+
- The missed gap arrives after join as `replay` frames, oldest-first, each with an `id` to ack. ⚠ A first join gets `cursor: "0"`: the WHOLE retained stream replays, repeating a `conversation` transcript.
|
|
950
|
+
- 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.
|
|
951
|
+
- 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.
|
|
952
|
+
- **What a message handler RETURNS decides delivery, and the failure directions are NOT symmetric.**
|
|
953
|
+
- A returned value fans out per `deliverTo` and becomes the transcript row.
|
|
954
|
+
- Returning NULL delivers NOTHING — the supported way to veto a message (the sender is told `dropped: true`).
|
|
955
|
+
- A payload REJECTED by the declared `input` also delivers nothing; the detail goes ONLY to the sender.
|
|
956
|
+
- ⚠ 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.
|
|
957
|
+
|
|
958
|
+
# Triggers
|
|
959
|
+
|
|
960
|
+
> 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.
|
|
961
|
+
|
|
962
|
+
**A trigger's `stack` is a callback — `stack: (t) => [...]` — to read a trigger
|
|
963
|
+
input** (a plain list works when it reads none). That's the one
|
|
964
|
+
shape that doesn't carry over from the other kinds: a trigger has no
|
|
965
|
+
user-declared `input`, so its inputs are **implied by type** (fixed by Xano,
|
|
966
|
+
not editable) and arrive through the typed **stack handle** `t` — you can't
|
|
967
|
+
reference them without it. (`response` too — a callback, or a plain value that
|
|
968
|
+
reads none.) `t` exposes exactly that trigger type's inputs; a wrong
|
|
969
|
+
name is a compile error, not a runtime surprise. The seven trigger types are
|
|
970
|
+
distinct root factories (not a namespace): `{tableTrigger, realtimeServerTrigger,
|
|
971
|
+
realtimeChannelTrigger, mcpServerTrigger, agentTrigger, workspaceTrigger,
|
|
972
|
+
errorTrigger}({ name, guid?, description?, active?, tags?, history?, ... })`.
|
|
973
|
+
`history` is per trigger (omit to inherit the workspace's `history.trigger`, default
|
|
974
|
+
off); a trigger's own failure reaches the error log only while it is on.
|
|
975
|
+
|
|
976
|
+
- `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). ⚠ Insert's `old` and delete's `new` are `{}`, not null — branch on `t.action`, not a null check. Config-only (no response).
|
|
977
|
+
- `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).
|
|
978
|
+
- `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` below (EMPTY/FALSY DENIES — INCLUDING a gating trigger with NO `response`, which returns nothing and so refuses every client).
|
|
979
|
+
- A CRASH DENIES too — a gate that cannot answer must not admit. 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.
|
|
980
|
+
- Gating is OPT-IN: a server with no `connect` trigger accepts every connection.
|
|
981
|
+
- `disconnect` is OBSERVATIONAL (return ignored, throws swallowed — cleanup must always complete).
|
|
982
|
+
- Both are SERVER-scoped, so `s.realtime.get_session` works but carries no channel path and no bound params.
|
|
983
|
+
- `realtimeChannelTrigger({ name, channel, actions?: {join?,leave?,deliver?}, stack?, response?, responseShape? })` — realtime CHANNEL lifecycle. Inputs: `t.action` (`join|leave|deliver`), `t.channel`, `t.payload`, `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:
|
|
984
|
+
- `join` GATES the join (it runs before membership) — 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. ONCE the object carries an `allowed` key admission needs STRICTLY `true` — `1`/`"yes"` there DENIES. Compute it as `c.expression("…")` or a `ref()` to a `set_var` boolean (`expr()` is not a `response` value). That is the inverse of a crashing message, which still delivers, and of `deliver` below.
|
|
985
|
+
- A lifecycle trigger's inputs are PINNED to those four, so a channel PATH PARAM is NOT among them — `inp("room_id")` RAISES, which crashes the gate and so REFUSES every client; take the param from `s.realtime.get_session` (`ref("session.params.room_id")`).
|
|
986
|
+
- A gate establishes NO auth: `auth("id")` reads 0 when authenticated, `ref("auth.id")` raises — identity is `t.client("permissions.row_id")` or the session. A SERVER connect/disconnect has no channel, so no params at all.
|
|
987
|
+
- `leave` is OBSERVATIONAL (return ignored, throws swallowed).
|
|
988
|
+
- `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.
|
|
989
|
+
- **`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.
|
|
990
|
+
- The delivered payload arrives NESTED, so read `t.payload("<field>")`, and `t.client` is the SENDER while `s.realtime.get_session` describes the RECIPIENT this run is for.
|
|
991
|
+
- `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`; an MCP server's also `t.prompts`/`t.resources`: return one filtered to narrow what the connection lists, omit it to list all — and `t.auth`, the signed-in user `{dbo, id, extras}` when the server has `oauth` (null otherwise). Response-bearing; the default returns every list input.
|
|
992
|
+
- `workspaceTrigger({ name, actions?: {branch_live?,branch_merge?,branch_new?}, stack? })` — branch lifecycle. Inputs: `t.to_branch`, `t.from_branch`, `t.action`. Config-only.
|
|
993
|
+
- `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.
|
|
994
|
+
|
|
995
|
+
# Array and database statements
|
|
996
|
+
|
|
997
|
+
> Read when the stack reads or writes rows (`s.db.*`), or transforms an array in place (`s.array.map`, `s.array.union`).
|
|
998
|
+
|
|
999
|
+
Array blocks (an `if`/`transform` is applied per item):
|
|
1000
|
+
|
|
1001
|
+
- `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`.
|
|
1002
|
+
- `s.array.union({ source, with, as?, transform? })` — set-union two arrays. `with` is REQUIRED; omitted → `Unable to locate input: `.
|
|
1003
|
+
|
|
1004
|
+
DB reads/writes (`table` is a def handle or name; `fieldName` defaults to the
|
|
1005
|
+
primary key `id`):
|
|
1006
|
+
|
|
1007
|
+
- `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).
|
|
1008
|
+
- `s.db.has({ table, fieldName?, fieldValue, as? })` — existence test.
|
|
1009
|
+
- `s.db.del({ table, fieldName?, fieldValue, as? })` — delete by field match.
|
|
1010
|
+
- `s.db.add({ table, row?, data?, output?, as? })` — insert; `row` is a partial keyed by column.
|
|
1011
|
+
- 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`.
|
|
1012
|
+
- `null` is accepted on EVERY column, including ones that refuse every other literal, and encodes `const:null` — a write OF null, not the same as omitting the key. A column's `nullable` is not consulted at encode; the engine refuses a null it forbids.
|
|
1013
|
+
- Omitting a key on `add` writes the column's type default — declared `default` if set, `[]` for a list, `{}` for obj/json, else `null`. That `null` is EMITTED, not what the row holds: the engine applies the column's nullability, so a `nullable` column keeps `null` and a non-nullable one lands on its type's zero value (`""`, `0`, `false`). Set the cell when the stored value matters. On `edit` an omitted key keeps its stored value.
|
|
1014
|
+
- 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.
|
|
1015
|
+
- A `table({ seed })` cell hashes the same way: the import writes the plaintext through the column's own rules, so a seeded credential matches under `security.check_password` exactly as an added one does. Demo accounts work as fixtures — the usual caution about seed data applies, since the plaintext sits in the repo.
|
|
1016
|
+
- `s.db.edit({ table, fieldName?, fieldValue, row?, data?, output?, as? })` — update by field match.
|
|
1017
|
+
- `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` — merge a partial. ⚠ Unlike `db.edit`'s `row`, `data` is a single object `Value` — write `obj({ unread: c.int(0) })`, not the column-keyed record `row` takes.
|
|
1018
|
+
On these three, `output` restricts the columns of the RETURNED row only — it does not change
|
|
1019
|
+
what is written. Not offered on `db.del`/`db.has` (their result is a scalar) or on
|
|
1020
|
+
`db.add_or_edit` (no output envelope).
|
|
1021
|
+
- `s.db.add_or_edit({ table, fieldName?, fieldValue, row?, data?, as? })` — upsert.
|
|
1022
|
+
- `s.db.query({ table, where?, additionalWhere?, bind?, sort?, paging?, external?, returnType?, distinct?, eval?, output?, lock?, addon?, as? })` — search.
|
|
1023
|
+
- `where` / `additionalWhere` — `expr(...)`, an `expr[]` (ANDed), or a raw `Value`. Rides `context.search`.
|
|
1024
|
+
- ⚠ `ignoreEmpty` DROPS the predicate when the operand is empty (not zero rows). On `in` an empty list returns the UNFILTERED set, so never scope to permitted ids with it; a `bulk.delete` only it scopes throws.
|
|
1025
|
+
- 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.
|
|
1026
|
+
- ⚠ `like`/`ilike` take the operand as the PATTERN, verbatim: a bare term matches only an exact whole-string equal, and the endpoint answers HTTP 200 with zero rows — nothing reports a problem, so a search box that matches nothing ships. For substring matching use `includes`/`not includes`, which wrap the operand in `%…%` themselves and match case-INSENSITIVELY. Prefer them over a hand-built `"%" + term + "%"`, which is non-empty even for an empty term and so defeats `ignoreEmpty`; `includes` composes with it. `contains`/`@>`/`overlaps` are JSON/array containment, not text — on a text column they 400 `ParseError: Invalid value for param`.
|
|
1027
|
+
- Compose nested boolean logic with `and(...)` / `or(...)` groups (also available on `addon()` `where`).
|
|
1028
|
+
- 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.
|
|
1029
|
+
- ⚠ 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 (`qf.epochms_add_day(7)`, `qf.epochms_sub_month(1)`, `qf.epochms_year("UTC")`, …) — or compute it in an earlier `s.set_var` and `ref()` that. `export --strict` reports it; a bare `c.now()` operand is always fine.
|
|
1030
|
+
- `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.
|
|
1031
|
+
- ⚠ 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.
|
|
1032
|
+
- `bind: [{ table: team, as: "team_row", join: "left", where: expr(col("team"), "=", col("team_row.id")) }]`
|
|
1033
|
+
- ⚠ A join does NOT put the joined table's columns on the returned row — with or without a `bind`, a row is the QUERIED table's columns, which is what `InferResponse` types. There is no `row.team_row`. To read a joined column, PROJECT it with an `eval` whose `name` is the dotted path: `eval: [{ name: "team_row.name", as: "team_name" }]` puts `team_name` on the row and on the inferred type. A bare `name` there is `Unsupported parameter reference` at runtime (it qualifies to the base table), and a dotted joined column in `output` is dropped with no error.
|
|
1034
|
+
- `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") }`.
|
|
1035
|
+
- `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.
|
|
1036
|
+
- An `eval`/`sort`/`where` filter pipeline compiles to **SQL**: `trim`/`lower`/`concat` work there too, plus a DIFFERENT registry than `fl.*`: the vector family, geo `distance`/`within`/`covers`, `search_rank`, the timestamp family, the aggregators. Build these with **`qf.*`** — `filters: [qf.vector_cos_distance(inp("q"))]` — which emits the same `{ name, arg }` step with the name, the argument COUNT and any enumerated argument checked at the call site. `qf` carries what the engine registers, including facts no name list holds: the per-part extractors take a timezone (`qf.epochms_month("UTC")`), `qf.round`'s precision is required, `qf.time`'s part is an enum. `QUERY_FILTER_NAMES`/`QUERY_AGGREGATE_NAMES` list the set; the raw `{ name, arg }` form still works for anything `qf` does not carry (`vector_distance`, which has no registered engine class).
|
|
1037
|
+
- **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.
|
|
1038
|
+
- `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`.
|
|
1039
|
+
- ⚠ 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.
|
|
1040
|
+
- 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.
|
|
1041
|
+
- `sort: [{ sortBy: <col>, dir?: "asc"|"desc"|"rand" }]` and `paging: { page?, per_page?, offset?, totals?, metadata?, search?, sort? }` ride `context.return.list`.
|
|
1042
|
+
- ⚠ `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. `paging: { metadata: false }` keeps the bare array.
|
|
1043
|
+
- ⚠ Under the ENVELOPE, `output` selects from the envelope, not the row: write `output: ["itemsReceived", "curPage", "items.id", "items.title"]`. A bare column list matches no envelope key, so every key is dropped and the response is `[]` at HTTP 200 with no error (and `ref("<var>.items")` then fails on a var that is gone). With `metadata: false` the statement binds the rows, so bare columns are the right form there. `export()` warns on a row-rooted selection under an envelope; `--strict` fails it.
|
|
1044
|
+
- Read `nextPage` (`number|null`) as the typed has-next signal.
|
|
1045
|
+
- **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.
|
|
1046
|
+
- A `search`/`sort`-only `paging` (no numeric field) does NOT paginate.
|
|
1047
|
+
- `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.
|
|
1048
|
+
- `distinct` — `"auto"` (default) | `"yes"` | `"no"`, riding `context.return.<list|stream>.distinct`.
|
|
1049
|
+
- `s.db.truncate({ table, reset?, as? })` · `s.db.schema({ table, path, as? })`.
|
|
1050
|
+
- `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.
|
|
1051
|
+
- Template placeholders are Twig over the request scope: `{{ $input.name }}` for an input, `{{ $var.name }}` for a stack variable. ⚠ A BARE `{{ name }}` renders as the empty string — HTTP 200, no error, a query that silently ran with a blank where the value belonged. A bound `?` arg carries a VALUE without the template at all.
|
|
1052
|
+
- ⚠ A table's PHYSICAL name is **not stable across deploys**. A deploy is a full replace, so every table is created afresh and the id in its name moves every time — the same unchanged project redeployed three times gave one table three different names. Never store, cache, hardcode or fixture one: resolve it from `information_schema` inside the same request that uses it.
|
|
1053
|
+
- `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.
|
|
1054
|
+
- `s.db.transaction({ body, as? })` — run a `Statement[]` atomically. `as` binds whatever the block returned.
|
|
1055
|
+
- `s.db.bulk.add({ table, items, allowIdField?, as? })` / `s.db.bulk.update` / `s.db.bulk.patch` — `items` is an array `Value`.
|
|
1056
|
+
- ⚠ `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).
|
|
1057
|
+
- ⚠ **`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.
|
|
1058
|
+
- `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 (missing, empty, or `x OR and()`) deletes **every** row, so it **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.
|
|
1059
|
+
- `s.db.increment({ table, where, fieldName, value, returnType?, output?, addon?, as? })` — adds `value` (a number or `Value`; negative decrements) to one `int`/`decimal` column on every row `where` matches, in ONE atomic locked UPDATE. **Use it for counters, stock, balances and tallies**: `db.get` → math → `db.edit` loses updates under concurrency, and so do `db.bulk.patch`/`update`. A guard in `where` (`expr(col("stock"), ">=", c.int(1))`) is re-checked under the row lock. `returnType: "count"` binds the changed-row count instead of the rows, and then takes no `output`/`addon`.
|
|
1060
|
+
- ⚠ A `where` that constrains nothing (missing, empty, or `x OR and()`) changes NO rows and **throws**. A null stored value counts as 0. A fractional `value` on an `int` column, a non-numeric or list column, the `id` key, and a result that overflows the column are rejected with no rows changed.
|
|
1061
|
+
|
|
1062
|
+
# Statement runtime behavior
|
|
1063
|
+
|
|
1064
|
+
> Read when you need to know what a statement's `as:` output holds, or why a bound variable is not the shape expected.
|
|
1065
|
+
|
|
1066
|
+
Runtime behavior (what the `as:` output holds, and misses):
|
|
1067
|
+
|
|
1068
|
+
- `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.)
|
|
1069
|
+
- `db.edit` binds the **full, post-mutation row**. `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`**, not the deleted row.
|
|
1070
|
+
- 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: HTTP 500 `ERROR_FATAL` "Duplicate record detected…", earlier writes kept. No error type is 409. `s.try_catch` catches it — and `s.precondition`/`guard.*` failures, answering 200 (`caught("code")`: `ERROR_CODE_NOT_FOUND`, …) — so re-raise with an `s.precondition` in `catch` to keep a status. No partial unique index: NULLs never collide, so null the key (or delete the row) to free it.
|
|
1071
|
+
- **`InferResponse<typeof query>`** derives an endpoint's response type. 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:
|
|
1072
|
+
| statement | resolves to | on a miss |
|
|
1073
|
+
|---|---|---|
|
|
1074
|
+
| `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 |
|
|
1075
|
+
| `db.get` | `Row \| null` | binds `null` rather than throwing |
|
|
1076
|
+
| `db.query` / `db.bulk.patch` | `Row[]` | — |
|
|
1077
|
+
| `db.has` | `boolean` | — |
|
|
1078
|
+
| `db.bulk.delete` | `number` (count) | — |
|
|
1079
|
+
| `db.increment` | `Row[]` (updated rows), or `number` with `returnType: "count"` | `[]` / `0` |
|
|
1080
|
+
| `db.del`, `db.bulk.add`/`bulk.update`, raw `direct_query` | `unknown` (the engine leaves them untyped) | — |
|
|
1081
|
+
- 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`).
|
|
1082
|
+
- A value reshaped by a lambda, or a variable built by control flow, also resolves to `unknown`.
|
|
1083
|
+
- 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`; an async `s.function.run` binds its job id (`string`), not the result.
|
|
1084
|
+
- In an object-literal response the KEYS are always known; a `c.*` constant VALUE types like an `s.set_var` of it (`{ success: c.bool(true) }` → `{ success: boolean }`).
|
|
1085
|
+
- 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`).
|
|
1086
|
+
- 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.
|
|
1087
|
+
- 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.
|
|
1088
|
+
- 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 bound ONLY inside branches → `responseShape` (a branch/case/loop/`try`/`catch` re-binding unions in; one in EVERY branch replaces); a call whose target is named by STRING → give the TARGET a `responseShape` (a handle then carries it) or declare it here. Use `responseShape` as the FIRST move here.
|
|
1089
|
+
- An `s.set_var` holding a `c.*` CONSTANT brands its binding with that constant's JS type, so `ref("n")` after `s.set_var("n", c.int(1))` resolves to `number` (widened: `c.bool(false)` → `boolean`; `s.update_var` re-binds). A filter chain — the value's own or `asFilters` — then retypes it (`[fl.to_int()]` → `number`). A bare `ref`/`inp` stays `unknown`.
|
|
1090
|
+
- ⚠ A hand-written `s.precondition` does not narrow the STATIC type. One (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. `guard.found("row")` DOES narrow: it emits the same non-null check (404) and drops `| null` from `row` — whole, dotted, and addon grafts alike — after it. `guard.owner` narrows the same way. Reach for `responseShape` only when neither fits.
|
|
1091
|
+
- ⚠ 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. A `merge` one makes a LIST response an index-keyed object; its keys are not inferred.
|
|
1092
|
+
- ⚠ **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.
|
|
1093
|
+
- **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`.
|
|
1094
|
+
- `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.
|
|
1095
|
+
- `input` maps addon inputs — bind a parent-row column with `out(col)`. `output` restricts addon columns. `children` nests addons.
|
|
1096
|
+
- The DEFINITION side — the `addon({...})` factory and `registerAddons` — is a def shape in `llms/kinds-core.md`.
|
|
1097
|
+
- Attaching a typed `addon({ table, output })` handle merges its alias (the last `as` segment) onto the row in `InferResponse`: `{cols} | null` 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).
|
|
1098
|
+
- An attachment-level `output` narrows an object/array graft further. A bare-NAME reference grafts `unknown` — narrow it at the call site.
|
|
1099
|
+
- ⚠ An alias that shadows an existing column on the queried table throws at build time; rename with a `_` prefix.
|
|
1100
|
+
- **Middleware attachment** runs a reusable `middleware({...})` before/after a host's own stack. Distinct from `s.middleware.call` (inline invoke).
|
|
1101
|
+
- 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.
|
|
1102
|
+
- 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).
|
|
1103
|
+
- Prefer a def handle over a bare name when the middleware pins an explicit `guid`.
|
|
1104
|
+
- `pre: middleware.clear()` (an empty list) overrides with nothing — stop inheriting.
|
|
1105
|
+
- 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.
|
|
1106
|
+
- **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 there is no caller identity for it to resolve.
|
|
1107
|
+
- 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 `auth()` cannot resolve, so the request FAILS with a 403 on the first call, under `max`, with the host never running. Key off `sys.remoteIp()` on a public host.
|
|
1108
|
+
- To catch that, `export()` **warns** (never blocks) when a middleware whose stack references `auth()` reaches 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.
|
|
1109
|
+
- Inherited attachment (API group, workspace tier) warns too.
|
|
1110
|
+
- **Rate-limit recipe (the canonical 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.") }) ] })`.
|
|
1111
|
+
- `exceptionPolicy` defaults to `"rethrow"`, which is what makes a tripped limit abort with HTTP 429; `"silent"` would let the over-limit request through. Without `error` it never trips: it binds `false` and the request runs on (`export()` warns).
|
|
1112
|
+
- Attach it with `middleware: { pre: [writeRl] }` on an **authenticated** host; on a public host key off `sys.remoteIp()`.
|
|
1113
|
+
- **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.
|
|
1114
|
+
- **Middleware `exceptionPolicy`** governs what a **throw** in the middleware stack does to the request. `"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.
|
|
1115
|
+
|
|
1116
|
+
- **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.
|
|
1117
|
+
- **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.
|
|
1118
|
+
- Per-kind defaults (when inheriting): query/task/tool capture ON, function/trigger/middleware OFF; default depth 100.
|
|
1119
|
+
- 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.
|
|
1120
|
+
- 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.
|
|
1121
|
+
|
|
1122
|
+
- **Workspace environment variables** set a tenant's env vars through the workspace object: `workspaceConfig({ env: { STRIPE_KEY: "", APP_BASE_URL: "" } })`, passed to `registerWorkspace()` — unattached, it declares nothing. Author them as a name→value MAP, declaring each NAME with an EMPTY value. Read a var back with `env("NAME")` (→ `$env.NAME`), which compiles to tag "setting" with the plain name.
|
|
1123
|
+
- Values are SECRETS and do NOT belong here: they live in `xano/.env`, which is gitignored and survives a `xanosdk pull`, and every command that compiles a bundle reads it with no flag. Fill it by hand from `xano/.env.example`, or run `xanosdk env pull`. CI passes `--backend-env-file <path>` or `--env-var KEY=VALUE` instead. A compiled bundle carries the values in cleartext — don't commit one.
|
|
1124
|
+
- What a deploy sends is the RESOLVED map (the declared names, then ONE file — `xano/.env`, or `--backend-env-file <path>` which REPLACES it, never both — then `--env-var`), not the literal object written here. `deploy` REPLACES the tenant's env with it, so a declared name with no value anywhere REFUSES the deploy rather than clearing the live value — fill the name in rather than putting the value back in source, and reach for `--allow-empty-env=NAME` only when clearing is what you mean.
|
|
1125
|
+
|
|
1126
|
+
- **Workspace documentation** gates the workspace's hosted docs: `workspaceConfig({ documentation: { require_token: true } })`. `require_token: true` IS the declaration that a token exists; the value lives in `xano/.secrets.json`, keyed by the object it gates rather than by a name, and a literal `token` FAILS the export. It is NOT a backend env var — `env()` cannot read it. Other members are carried verbatim.
|
|
1127
|
+
- ⚠ OMIT the block and NO `documentation` key is emitted, leaving the target's live block as it is; the legacy `realtime` block follows the same rule. An `apiGroup`'s `documentation` follows the OPPOSITE rule — see its def shape.
|
|
1128
|
+
- A declared gate with no token emits no block at all and REFUSES a `deploy`; `--allow-empty-doc-token=<scope>` clears that gate deliberately (scope = `workspace`, or an API group's NAME). `xanosdk pull` writes `xano/.secrets.json`, and `--no-secrets` writes none.
|
|
1129
|
+
- A merge (`deploy --to`) is ADD-ONLY, matched BY NAME — it creates names the target lacks but does NOT update or remove ones it has, so the target's value wins. Two gaps: a release carries no env values, so a NEW name reads null after `promote`/`tenant deploy` until `env set` (both name it first) — and a `--replace` deploy rebuilds it so every env var the target holds that the config does not declare is DROPPED (named, and confirmed, before it runs). Omit `env` entirely to leave existing env untouched. The separate `settings` field is a plain object.
|
|
1130
|
+
|
|
1131
|
+
# Auth, cross-object calls, and microservices
|
|
1132
|
+
|
|
1133
|
+
> Read when the stack authenticates or verifies a webhook (recipes here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), sends email (`s.util.send_email`), or reaches a microservice.
|
|
1134
|
+
|
|
1135
|
+
Auth & calls:
|
|
1136
|
+
|
|
1137
|
+
- `s.security.create_auth_token({ table, id, extras?, expiration?, as? })` — `extras` defaults to `{}`, `expiration` (seconds, a `Value`: `c.int(3600)`) to 86400 (`c.int(0)` = never).
|
|
1138
|
+
- **Marketplace first:** `@xano-sdk/auth` registers a complete auth surface — signup/login/me endpoints plus their `user`/`account`/`event_log` tables — via `registerAuth(ws, { canonical: "authn" })`. Authentication only, not authorization. Inspect with `xanosdk marketplace details @xano-sdk/auth --prompt`.
|
|
1139
|
+
- **The auth loop end to end** — what that module implements; author it directly for a custom flow. An auth TABLE (`table({ auth: true })`) backs identity; a PUBLIC login query verifies and mints a token; every protected query names that table as `auth:` and reads the caller with `auth(...)`.
|
|
1140
|
+
- Signup: `s.db.add({ table: users, row: { email: inp("email"), password: inp("password") } })` — the `f.password()` column hashes on write; take the submission as `input.text()` on signup AND login (see Gotchas on double-hashing).
|
|
1141
|
+
- Login: `s.db.get({ table: users, fieldName: "email", fieldValue: inp("email"), output: ["id", "password"], as: "u" })` — the `output` naming `password` is REQUIRED (the column is `access: "internal"` and absent from the row otherwise) → `s.precondition({ expr: expr(ref("u", { safe: true }), "!=", c.null()), error: c.text("No such user."), error_type: "notfound" })` → `s.security.check_password({ text_password: inp("password"), hash_password: ref("u.password"), as: "ok" })` → precondition on `ok` → `s.security.create_auth_token({ table: users, id: ref("u.id"), as: "token" })` → `response: { token: ref("token") }`.
|
|
1142
|
+
- Client: send it as an `Authorization: Bearer <token>` header. A `query({ auth: users })` refuses a request without a valid one before its stack runs; inside the stack `auth("id")` is the caller's row id (`response: { id: auth("id") }` is the whole `me` endpoint).
|
|
1143
|
+
- **Authorization, on top of that.** `guard.role(users, "admin")` and `guard.owner("note", "user_id")` each return a fixed-arity TUPLE of statements — spread them (`stack: [...guard.role(users, "admin"), …]`); a helper returning `Statement[]` widens the stack and kills `InferResponse`.
|
|
1144
|
+
- `guard.role(table, role | roles[], { field?, as?, errorType?, message?, missingErrorType?, missingMessage? })` — fetches the caller's own row by `auth("id")` into `__actor`, asserts it EXISTS (401), then asserts its `role` is in the set (403). Roles type from the enum column; an unknown role or `field` throws. The existence step matters: a role check on a null actor row is not reliably false.
|
|
1145
|
+
- `guard.owner(rowVar, ownerField = "user_id", { … })` — asserts the row bound to `rowVar` is non-null (404), THEN compares its owner column to `auth("id")` (also 404). Drilling before the existence check is a 500, not a denial: `db.get` binds `null` on a miss and `ref("note.user_id")` against it raises `Unable to locate var`. Takes the var NAME and the column separately for exactly that reason; a dotted path is refused.
|
|
1146
|
+
- `guard.found(rowVar, { errorType?, message? })` — ONE statement: asserts the row bound to `rowVar` is non-null (404, `"Not found."`), and NARROWS it — after it, `InferResponse` drops the `| null` from `rowVar`. `guard.owner`'s existence step is this statement.
|
|
1147
|
+
- `guard.require(condition, { errorType?, message? })` for anything else — same shape, and it defaults to 403 where a bare `s.precondition` defaults to `standard`, i.e. **500**.
|
|
1148
|
+
- `s.security.create_uuid({ as? })` — bind a fresh UUID (v4). Takes nothing else. There is NO `create_guid` (internal, unpushable).
|
|
1149
|
+
- **Receiving a webhook** — no `auth`, no declared `input` (the signature covers the raw bytes): `s.util.get_raw_input({ encoding: "none", as: "raw" })` → `s.set_var("mac", withFilters(ref("raw"), fl.hmac_sha256(env("WH_SECRET"))))` → `s.set_var("sig", withFilters(sys.httpHeaders(), fl.get("X-Signature")))` (Title-Cased) → an `s.precondition` (`error_type: "unauthorized"`) comparing the two TIMING-SAFELY by HMACing both again — `expr(withFilters(ref("sig"), fl.hmac_sha256(env("WH_SECRET"))), "=", withFilters(ref("mac"), fl.hmac_sha256(env("WH_SECRET"))))` — BEFORE `fl.json_decode()` reads `raw`. Claim the event id with `s.redis.set({ …, create_only: c.bool(true) })` and answer 200 to a replay.
|
|
1150
|
+
- `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 (an undeclared one is a type and export error; generic over `fn`, pass `Record<string, Value>`; `2` binds an int — `c.decimal(2)` for a decimal). ⚠ `s.function.run` binds an input it OMITS from the CALLING def's same-named declared input — an endpoint declaring `discount` hands `?discount=100` through, or `0` when the client sends none. An endpoint/tool/prompt declaring NO inputs binds it from the raw request. `ignored()` always takes the target's default. Pass inputs explicitly (`statement.omitted-input` warns).
|
|
1151
|
+
- `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, and `as` binds the job id (a UUID `string`); collect with `s.await({ ids, timeout, as })`, binding `{ "<jobId>": result }`; a too-short `timeout` or unknown id is a 500 `Async timeout`, no partial result. Omit for a normal call; same on `s.ai.agent.run`.
|
|
1152
|
+
- `s.api.call({ api, input?, headers?, auth?, as? })` — invoke an endpoint. WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`).
|
|
1153
|
+
- `api` takes the `query()` def HANDLE (or a `{ name, guid }` pair) — a bare name is refused, because a query's identity is composed from its api group, verb, and name.
|
|
1154
|
+
- `headers` REPLACES the request headers the callee sees and takes the same shapes as `s.api.request`'s, or one `Value`.
|
|
1155
|
+
- `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.
|
|
1156
|
+
- Neither slot authenticates the call today; see `llms/tests.md` for what a workflow-test run actually sees.
|
|
1157
|
+
- `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`:
|
|
1158
|
+
- `method` suggests the 7 verbs (GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH).
|
|
1159
|
+
- `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).
|
|
1160
|
+
- `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 reaches access logs and proxies.
|
|
1161
|
+
- ⚠ 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`.
|
|
1162
|
+
- A NAME outside the header-token charset is refused, and a LITERAL value (string or `c.text`) carrying CR/LF/NUL 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 are emitted before computed ones, whatever the key order.
|
|
1163
|
+
- No `User-Agent` is sent unless `headers` carries one. An API requiring one answers **403** as ordinary `response.result` (no throw; every mapped field reads `null`). Send one (`headers: { "User-Agent": c.text("my-app") }`) and gate on `ref("<var>.response.status")` = 200 with `s.precondition`.
|
|
1164
|
+
- A transport failure (timeout, DNS) does not throw — `s.try_catch` never catches it: `response.status` is `0`, with `response.error` set. Retry on `status == 0 || status >= 500`; `status < 500` passes a 0.
|
|
1165
|
+
- `timeout` a `number` in seconds (1–86400), and `follow_location`/`verify_host`/`verify_peer` booleans.
|
|
1166
|
+
- `description` and `output` filters ride the envelope. Cert pairings (certificate↔private_key, ca_certificate→verify_peer) are checked at build time when provable.
|
|
1167
|
+
- 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`.
|
|
1168
|
+
- `s.stream.from_request({ url, method?, …tls, as? })` — streaming external HTTP request (`mvp:streaming_api_request`); same typed field surface as `s.api.request` (no description/output envelope). `url` is REQUIRED.
|
|
1169
|
+
- `s.util.send_email({ to, subject, message, from?, cc?, bcc?, reply_to?, service_provider?, api_key?, scheduled_at?, as? })` — send email from the stack. `service_provider` is `"xano"` (the built-in mailer — needs NO `api_key` and no configuration, and does not require a verified sender) or `"resend"` (pass the key as `api_key: env("RESEND_API_KEY")`). Prefer this over hand-rolling `s.api.request` against a mail provider.
|
|
1170
|
+
- `s.webflow.request({ path, method?, …tls, as? })` — Webflow API request (`mvp:connect_webflow_api_request`); like `s.api.request` but addressed by `path` (host is engine-supplied), and `path` is REQUIRED — the engine rejects an empty one. No `headers`: the engine builds its own from the workspace's Webflow connection and ignores an authored value.
|
|
1171
|
+
- `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.
|
|
1172
|
+
- `s.action.call({ actionId, input?, registry?, as? })` / `s.action.package.call({ traceId, versionId, slug, input?, registry?, as? })` — invoke an installed action. Ids are SUPPLIED, never derived from a name: an action is installed onto the instance, so its identity is assigned at install and differs per instance — read them off a call in a pulled workspace. The package form needs all three parts; they are one composite and two of them address nothing. `registry` is the action's own settings, `input` the per-call arguments.
|
|
1173
|
+
- `s.cloud.job({ image, command, args?, secret?, template?, await?, as? })` — launch a containerized job. `image`/`command` are REQUIRED; omitted → `Missing param`. ⚠ `await` is SECONDS (default 60), not a boolean.
|
|
1174
|
+
- `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).
|
|
1175
|
+
|
|
1176
|
+
Microservices (the `microservice()` def and the statement that calls it):
|
|
1177
|
+
|
|
1178
|
+
- `microservice({ name, kind, … })` — 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.
|
|
1179
|
+
- EARLY SURFACE, expected to change — every export of a workspace declaring one prints a notice saying so.
|
|
1180
|
+
- `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.
|
|
1181
|
+
- Put a value the workload reads in a container's `env`, and storage in a container's own `volumes` (`emptyDir`/`persistent`/`config`).
|
|
1182
|
+
- Container names are free-form — they need not match the microservice name, which is what a stack addresses.
|
|
1183
|
+
- SECRETS RIDE ALONG — `chart.values` and `registryAuth.dockerconfigjson` are carried into the bundle, and into a pulled tree, verbatim. `process.env.X` in the def is NOT a way out: it resolves at EXPORT and writes the literal in.
|
|
1184
|
+
- Reference a `workspaceConfig({ env })` var instead and only the NAME travels — the engine substitutes at DEPLOY. Two spellings: `env: [{ name: "API_KEY", fromEnv: "STRIPE_SECRET" }]` on a builtin container (`value`/`fromEnv` exclusive; both → build error), or `${env.NAME}` inside a helm `chart.values` (a VALUE at any depth, never a key). A name the workspace does not define FAILS the deploy rather than resolving blank.
|
|
1185
|
+
- `registryAuth.dockerconfigjson` has NO such form: either leave it unset and pull a public image, or treat the bundle and any tree pulled from it as secret material — out of git, or rotate after. Export prints a notice per microservice carrying a literal; `--strict` does not promote it.
|
|
1186
|
+
- `s.microservice.request({ host, path, port?, method?, params?, headers?, timeout?, follow_location?, as? })` — in-cluster microservice call (`mvp:microservice_request`); no TLS fields.
|
|
1187
|
+
- ONLY `host`+`path` required; the rest default to the engine's values (`GET`/`{}`/`[]`/`10`/`true`), always emitted.
|
|
1188
|
+
- 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.
|
|
1189
|
+
- `port?` folds into `host` as `"name:port"`: a def exposing ONE `servicePort` resolves automatically, SEVERAL requires it.
|
|
1190
|
+
- A raw `"name:port"` string works, unvalidated, and is the only way to reach an instance-level microservice.
|
|
1191
|
+
- `tenantDeploy: "manual"` on the def imports the row without starting the workload.
|
|
1192
|
+
- `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.
|
|
1193
|
+
|
|
1194
|
+
# Value catalog
|
|
1195
|
+
|
|
1196
|
+
> 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.
|
|
1197
|
+
|
|
1198
|
+
- `c.text(s: string) => Value` — String constant → tag "const".
|
|
1199
|
+
- `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("9223372036854775807") is exact where the number literal for it is already …808. A number that is not a safe integer THROWS rather than encoding the rounded value. The VALUE encodes at any width, but an `f.int` COLUMN is signed 64-bit and CLAMPS past its ceiling rather than failing — see `llms/fields.md`.
|
|
1200
|
+
- `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).
|
|
1201
|
+
- `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).
|
|
1202
|
+
- `c.bool(b: boolean) => Value` — Boolean constant → tag "const:bool".
|
|
1203
|
+
- `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.
|
|
1204
|
+
- `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. Write c.array([...]) for a list, or prefix the keys ("k0") for an object; `export()` warns. 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.
|
|
1205
|
+
- `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.
|
|
1206
|
+
- `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.
|
|
1207
|
+
- `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.
|
|
1208
|
+
- `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 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"`. A null-safe ref(path, { safe: true }) works inside an obj() member directly. The member record rides the return type, so InferResponse (through a set_var too) resolves each member like 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. A constant record or list has two spellings that both work and render identically: bare (`{ a: 1 }`, `[]`) or `c.obj(...)`/`c.array([...])` — prefer the bare form. An inner obj() nests, same bytes as a raw record. The legacy blank `c.obj(null)` is refused here (it evaluates to null, not {}) — write c.null() or c.obj(). Still rejected: a filter ARGUMENT that carries its own chain or is a c.now() (a trailing | binds to the whole value, not one argument, and c.now() needs one), a DISABLED filter (an expression string cannot record that), and the output/response/trycatch/toolset/reg tags (a type error) — set_var those first and ref() them.
|
|
1209
|
+
- `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.
|
|
1210
|
+
- `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`).
|
|
1211
|
+
- `col(name: string) => Value` — Reference a table column → tag "col".
|
|
1212
|
+
- `auth(path?: string) => Value` — Reference the authenticated identity (auth("id") → $auth.id) → tag "auth".
|
|
1213
|
+
- `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.
|
|
1214
|
+
- `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.
|
|
1215
|
+
- `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. A name that does not exist resolves to NULL rather than erroring, so a typo deploys clean and fails downstream of the value: prefer typedEnv(config), which turns the names that config declares into properties (E.STRIPE_KEY), and export() warns stack.env-undeclared on any env() or bare setting() outside the declared set once a config declares env at all.
|
|
1216
|
+
- `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.
|
|
1217
|
+
- `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). sys.httpHeaders() keys arrive Title-Cased whatever the client sent: a client sending `x-sync-secret` is read as `X-Sync-Secret`, so `get:"x-sync-secret"` is null in production — read the Title-Cased name. s.api.call sends header names the same way, so a workflow test sees what a real request delivers.
|
|
1218
|
+
- `filter(name: string, ...args: Value[]) => FilterXdo` — Build a filter-chain entry by raw name (escape hatch).
|
|
1219
|
+
- `fl.*(...args: Value[]) => FilterXdo` — Typed value-pipeline filters; see the `filters` catalog.
|
|
1220
|
+
- `withFilters(value: Value, ...filters: FilterXdo[]) => Value` — Attach a filter chain to a value (filters passed spread; an array is also accepted).
|
|
1221
|
+
|
|
1222
|
+
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.
|
|
1223
|
+
|
|
1224
|
+
# Column and input types
|
|
1225
|
+
|
|
1226
|
+
> Read when declaring a table column (`f.*`) or a function/query input (`input.*`) — a type's options and methods, and the `s.precondition` error/status contract. Also `table({ seed })` rows and `use_xdo`.
|
|
1227
|
+
|
|
1228
|
+
Author table columns + function/API inputs with the typed catalog: `f.<type>(opts?)`
|
|
1229
|
+
for columns, `input.<type>(opts?)` for inputs. Common opts: `required`, `nullable`,
|
|
1230
|
+
`default`, `description`, `access` (a read omits `"internal"` columns, returns `"private"` ones; `InferRow`
|
|
1231
|
+
is the full row, both kept), `sensitive` (masks the input's value in request logs; a read still RETURNS a sensitive column — `access: "internal"` keeps it out).
|
|
1232
|
+
**`nullable` defaults PER TYPE, matching the engine's own column-creation API**: `true`
|
|
1233
|
+
for `f.vector`, `f.uuid`, every `f.geo.*` and every file type (`f.image`/`f.video`/
|
|
1234
|
+
`f.audio`/`f.attachment`), `false` for everything else (text, int, decimal, bool, email,
|
|
1235
|
+
enum, json, object, password, date, tableRef); inputs add date, timestamp and file, so an
|
|
1236
|
+
omitted one binds `null` (a `required` one still accepts an explicit `null`). Pass `nullable` to override —
|
|
1237
|
+
e.g. `f.geo.polygon({ nullable: false })`. An empty default becomes NULL only when
|
|
1238
|
+
nullable, so a non-null `f.vector(8)` column fails to create (`''` is not a vector).
|
|
1239
|
+
**`f.geo.*` values are `{ type, data }`, not GeoJSON.** The same shape goes in and comes
|
|
1240
|
+
back. `type` per column: `point` → `"point"`, `data` `{ lng, lat }`; `multipoint` → `"points"`,
|
|
1241
|
+
`linestring` → `"path"`, `polygon` → `"poly"` (ring closed for you), `data` `[{ lng, lat }, …]`;
|
|
1242
|
+
`multilinestring` → `"paths"`, `multipolygon` → `"polys"`, `data` `[[{ lng, lat }, …], …]`.
|
|
1243
|
+
Any other spelling (`"multipoint"`, `"linestring"`, `"Point"`) 400s `Only strings are supported for:`.
|
|
1244
|
+
Raw WKT text (`c.text("POINT(1 2)")`) is accepted on write too, but a read never returns one.
|
|
1245
|
+
`methods` (validators/transforms, names per type below): `"trim"`, `"min:8"`
|
|
1246
|
+
(a text arg keeps its `:`: `"startsWith:https://"`), or `{ name, arg }` — for unlisted names and `pattern`'s error text.
|
|
1247
|
+
`f.json({children})` declares the nested shape stored INSIDE a json column — an ARRAY of
|
|
1248
|
+
`{name, type, methods?, children?}`, order-significant, distinct from the `FieldMap` that
|
|
1249
|
+
`f.object` takes positionally. Omit it for an unstructured json column.
|
|
1250
|
+
`f.enum(values)`/`f.vector(size)`/`f.object(children)`/`f.tableRef(table)` take a
|
|
1251
|
+
positional payload before opts — and still accept the standard `FieldOptions` after it
|
|
1252
|
+
(`f.enum([])`/`input.enum([])` are accepted: a pulled enum with no options, not one to
|
|
1253
|
+
author — it brands the column `never`, which `InferRow` surfaces as `undefined`.)
|
|
1254
|
+
(e.g. `f.tableRef(users, { required: true })` — only `min`/`max` are listed as tableRef
|
|
1255
|
+
methods below, but `required`/`nullable`/`description`/… apply like any field.)
|
|
1256
|
+
An **OPTIONAL foreign key wants a `0` sentinel, not `nullable: true`**. `f.tableRef` to an int-keyed table stores
|
|
1257
|
+
an `int`, and a null in it is unqueryable: `null` is never a legal `fieldValue`/`id`, so
|
|
1258
|
+
`s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather
|
|
1259
|
+
than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for
|
|
1260
|
+
"not set yet" — `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and
|
|
1261
|
+
binds `null`, which is the answer the null was reaching for. `export()` warns on a literal
|
|
1262
|
+
`c.null()` in that slot. A uuid-keyed ref stays nullable even when `required` (a non-null one defaults to `""`, which no insert accepts).
|
|
1263
|
+
An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through
|
|
1264
|
+
any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:
|
|
1265
|
+
"vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see
|
|
1266
|
+
`s.db.query` → `eval`). Without that pairing the column stores and indexes but nothing
|
|
1267
|
+
queries it.
|
|
1268
|
+
`{ array: true }` makes any `f.*` scalar a **list column** — `f.text({ array: true })`
|
|
1269
|
+
surfaces as `string[]` in `InferRow<typeof table>` (the column analogue of `input.list`).
|
|
1270
|
+
**Seed a table's starting rows with `table({ seed })`.** `seed` takes rows
|
|
1271
|
+
typed against the table's schema as a WRITE shape (a column without
|
|
1272
|
+
`required: true`, and the system columns, may be omitted; `null` needs
|
|
1273
|
+
`nullable: true`) — inline (`seed: [{ name: "…" }]`), a FILE
|
|
1274
|
+
(`seed: seedFile("./seed.json", import.meta.url)`; path resolves against the DECLARING
|
|
1275
|
+
file), or a thunk (`seed: () => import("./seed.json")`, async ok, `.default` unwrapped).
|
|
1276
|
+
Inline rows are TYPED against the schema at compile time; a `seedFile`/thunk seed is
|
|
1277
|
+
not — `xanosdk export`/`deploy` validates it, naming the row index and column.
|
|
1278
|
+
Timestamp seeds: epoch ms, `Date`, ISO 8601 date, or date-time with `Z`/offset; else THROWS.
|
|
1279
|
+
Int seeds: whole, in int64 (past 2^53: a string).
|
|
1280
|
+
⚠ Prefer `seedFile` for a file: a thunk's `import()` sits in
|
|
1281
|
+
YOUR module, so a bundler emits the JSON as a served chunk. Never put secrets
|
|
1282
|
+
in `seed`. `deploy --static` REFUSES a build carrying internal/sensitive seed values;
|
|
1283
|
+
declare public ones (demo logins) on the table: `publicSeed: ["password"]`.
|
|
1284
|
+
A replacing deploy re-seeds cleanly; `--keep-data` writes no seed rows.
|
|
1285
|
+
`seed` is evaluated on EVERY build, so a computed value (`Date.now()`) is fine:
|
|
1286
|
+
the lock records no seed data.
|
|
1287
|
+
A FILE column seeds from a repo file: `logo: hostedFile("./logo.png", import.meta.url)`;
|
|
1288
|
+
the row stores each backend's own copy. A wrong-kind file or a non-file column THROWS.
|
|
1289
|
+
A release carries no file bytes: `release create` refuses such rows and icons.
|
|
1290
|
+
Omit `id` and rows auto-number `1..N` (int PK) or take
|
|
1291
|
+
a stable derived uuid (uuid PK); supplying `id` pins it (and
|
|
1292
|
+
resets an int sequence past the max). All-or-nothing — mixing explicit and
|
|
1293
|
+
omitted `id` throws. A `system:false` PK is the author's to supply. Pinning is
|
|
1294
|
+
`seed`-only — `s.db.bulk.add` DROPS `id` unless `allowIdField: true`.
|
|
1295
|
+
**`use_xdo` storage mode.** Workspace setting (`registerWorkspace({ use_xdo })`,
|
|
1296
|
+
default `false`): `true` stores fields as JSON in the `xdo` column (+ `gin(xdo)` index),
|
|
1297
|
+
`false` real columns.
|
|
1298
|
+
Tables inherit it; override with `table({ useXdo })`; a merge refuses to switch a populated table.
|
|
1299
|
+
A **column `default` must stay within the BMP** — a 4-byte character (codepoint > U+FFFF,
|
|
1300
|
+
e.g. an emoji) is rejected at export; put such a value on an `input.<type>({ default })`.
|
|
1301
|
+
`input.*` mirrors `f.*` — every column type below is
|
|
1302
|
+
a legal input (scalars, files `input.image/video/audio/attachment`, `input.geo.*`,
|
|
1303
|
+
`input.vector(size)`, `input.tableRef(table)`, `input.object(children)`), plus
|
|
1304
|
+
`input.dbLink(table)` is the odd one: ONE entry that EXPANDS into one input per
|
|
1305
|
+
COLUMN of the linked table, so read them by column name (`inp("email")`), never by
|
|
1306
|
+
the entry's own name. `hidden: ["created_at"]` drops columns from that expansion.
|
|
1307
|
+
`input.list(element)` for arrays — wrap any element constructor, e.g.
|
|
1308
|
+
`input.list(input.text())` or `input.list(input.object({ id: f.int() }))`.
|
|
1309
|
+
**A file reaches a column in two steps.** `input.file` is the RAW upload (the request's
|
|
1310
|
+
multipart/base64 bytes) and cannot be written to a file column directly: store it first —
|
|
1311
|
+
`s.storage.create_image({ as: "img", value: inp("avatar"), access: "public" })`
|
|
1312
|
+
(read the upload with `inp`; `ref("input.avatar")` spells a stack VARIABLE named
|
|
1313
|
+
`input` and fails the request with `Missing var entry: input`) — then write
|
|
1314
|
+
`ref("img")` into the `f.image()` cell with `s.db.add`/`edit`. `access` defaults to
|
|
1315
|
+
`"public"` (a guessable URL): pass `"private"` and hand out `s.storage.sign_private_url`
|
|
1316
|
+
results for anything user-scoped. `create_video`/`create_audio`/`create_attachment` are
|
|
1317
|
+
the same shape for the other file columns, each fed `input.file` (`input.image`/… 400s an upload).
|
|
1318
|
+
**An empty file column is a stored null that `where` does not see as null.** `expr(col("file"), "!=", c.null())`
|
|
1319
|
+
matches EVERY row, and `= c.null()` matches none. Compare a key inside the file instead:
|
|
1320
|
+
`expr(col("file.path"), "!=", c.null())` keeps only the rows holding a file.
|
|
1321
|
+
**Typed inputs validate/coerce on bind, before your stack runs** — so reach for the
|
|
1322
|
+
specific type instead of hand-rolling checks. `input.email({ required: true })` rejects a
|
|
1323
|
+
malformed address with a 400 (and trims; add `methods: ["lower"]` to downcase) — no
|
|
1324
|
+
`regex_matches` needed; `input.int`/`input.decimal`/`input.uuid`/`input.enum([...])`/`input.date`
|
|
1325
|
+
likewise reject or coerce bad input at the boundary. Drop to `input.text` + `s.precondition`
|
|
1326
|
+
only for rules no type expresses.
|
|
1327
|
+
⚠ `input.url` is NOT one of them — there is no engine `url` type, so it stores as `text`
|
|
1328
|
+
and validates NOTHING: a `javascript:`/`data:` URL type-checks, imports, and binds. When the value gets navigated
|
|
1329
|
+
to, check the scheme in the stack. It is INPUT ONLY — there is no `f.url` column.
|
|
1330
|
+
⚠ `input.timestamp` binds epoch MILLISECONDS. A number is taken as ms as-is, so epoch
|
|
1331
|
+
SECONDS (`date +%s`) bind in January 1970 with no error. An ISO-8601 string keeps its
|
|
1332
|
+
offset but DROPS fractional seconds (`"…T19:30:00.123Z"` binds `…:00.000`). Send epoch ms
|
|
1333
|
+
(`Date.now()`, `getTime()`) whenever sub-second precision matters.
|
|
1334
|
+
⚠ `input.list` does NOT reject a non-array: one value binds as a ONE-item list (`"a"` →
|
|
1335
|
+
`["a"]`), and a non-JSON string given to an `input.object` binds an object of its
|
|
1336
|
+
children's DEFAULTS — so `input.list(input.object({...}))` given `"notalist"` binds
|
|
1337
|
+
`[{ name: "", … }]` with a 200. A list `min` does not catch it (that is one item). A string
|
|
1338
|
+
starting with `[` is parsed as JSON with NO element check at all. When the shape must hold,
|
|
1339
|
+
mark an object child `required: true` (the default-filled object then 400s with its param
|
|
1340
|
+
path) and check anything else with `s.precondition`.
|
|
1341
|
+
⚠ `s.precondition`'s `error` must be a TAGGED value — `c.text("…")`, not a bare string.
|
|
1342
|
+
The engine falls back to the generic "Precondition failed." whenever it reads an empty or
|
|
1343
|
+
non-scalar message, and a bare string lands there, so the client never sees your text. The
|
|
1344
|
+
`error_type` → HTTP status mapping is correct either way; only the message is lost.
|
|
1345
|
+
`error_type` is how a stack sets a FAILURE status: `badrequest`/`inputerror` → 400, `unauthorized` → 401, `accessdenied` → 403, `notfound` → 404, `toomanyrequests` → 429, `standard` → 500.
|
|
1346
|
+
For any other status — and for a redirect — use `respond.*`, which is sugar over
|
|
1347
|
+
`s.util.set_header` (that statement reaches the status line): `respond.status(201)`,
|
|
1348
|
+
`respond.redirect(url, { status?: 301|302|303|307 })` (a TUPLE — spread it; a `Location`
|
|
1349
|
+
alone does NOT redirect), and `respond.header(name, value)`. Position in the stack does not
|
|
1350
|
+
matter, a later status wins, and a FAILING `precondition` still wins over anything set
|
|
1351
|
+
before it. ⚠ `204` answers with an empty body whatever `response` says. ⚠ `respond.status`
|
|
1352
|
+
REFUSES `308`/`425`/`451`/`102`/`103`: the platform writes those and does not act on them,
|
|
1353
|
+
so the response is 200 with nothing reported — use 301, 429 and 403 instead.
|
|
1354
|
+
Normalizing transforms run on bind too — put `trim`/`lower`/`upper` on the input's `methods`
|
|
1355
|
+
so `inp("name")` reads already-normalized; don't reroll `var $x = inp("name")|trim` in the stack.
|
|
1356
|
+
⚠ `f.decimal`'s size depends on the table's layout; it takes no precision or scale option.
|
|
1357
|
+
On a normalized table (`useXdo: false`, the default) it is FIXED: 5 decimal places out
|
|
1358
|
+
of 14 total digits. `0.12345678` written and re-read is `0.12346` (`-0.000004` → `0`),
|
|
1359
|
+
and the rounding is silent at HTTP 200 — for a scalar `s.db.add`'s `as` output carries the
|
|
1360
|
+
STORED value, not the cell you sent, so comparing the two detects the loss; for a list
|
|
1361
|
+
(`array: true`) it is the unrounded list you sent, so only a re-read shows it. Rounding happens BEFORE
|
|
1362
|
+
the size is checked, so a magnitude needing 10 digits left of the point AFTER rounding 500s
|
|
1363
|
+
`ERROR_FATAL` `SQL Error: 0`, naming neither column nor value: `999999999.5` stores,
|
|
1364
|
+
`999999999.999999` does not. On a `useXdo: true` table it is a JSON number with no 5dp
|
|
1365
|
+
rounding (`0.123456789` reads back `0.123456789`). On either layout, for anything finer than 5dp
|
|
1366
|
+
store an integer count of the smallest unit (cents, satoshis) in an `f.int`.
|
|
1367
|
+
⚠ `f.int` is a SIGNED 64-bit column — `-9223372036854775808` to `9223372036854775807` —
|
|
1368
|
+
and past the ceiling it CLAMPS instead of failing: `9223372036854775808` and
|
|
1369
|
+
`18446744073709551615` both store as `9223372036854775807`, at HTTP 200 with no error, so
|
|
1370
|
+
an overflowed count reads back as a plausible number. The response carries every digit, but
|
|
1371
|
+
`JSON.parse` (so `res.json()`) rounds past `9007199254740991` — read a count that large as
|
|
1372
|
+
text or BigInt. `c.int()` refuses an unsafe number literal, so pass the string.
|
|
1373
|
+
|
|
1374
|
+
- `f.text` — methods: alphaOk, digitOk, lower, max, min, ok, pattern, startsWith, trim, upper
|
|
1375
|
+
- `f.int` — methods: max, min
|
|
1376
|
+
- `f.decimal` — methods: max, min
|
|
1377
|
+
- `f.bool`
|
|
1378
|
+
- `f.uuid`
|
|
1379
|
+
- `f.date`
|
|
1380
|
+
- `f.email` — methods: lower, trim
|
|
1381
|
+
- `f.password` — methods: max, min, minAlpha, minDigit, minLowerAlpha, minSymbol, minUpperAlpha, salt
|
|
1382
|
+
- `f.json`
|
|
1383
|
+
- `f.timestamp` (stored `epochms`)
|
|
1384
|
+
- `f.image` (stored `blob_img`)
|
|
1385
|
+
- `f.video` (stored `blob_video`)
|
|
1386
|
+
- `f.audio` (stored `blob_audio`)
|
|
1387
|
+
- `f.attachment` (stored `blob`)
|
|
1388
|
+
- `input.file` — INPUT ONLY (no `f.` form)
|
|
1389
|
+
- `input.dbLink` (stored `<tableGuid>_mvpschema`) — INPUT ONLY (no `f.` form)
|
|
1390
|
+
- `f.geo.point` (stored `geo_point`)
|
|
1391
|
+
- `f.geo.multipoint` (stored `geo_multipoint`)
|
|
1392
|
+
- `f.geo.linestring` (stored `geo_linestring`)
|
|
1393
|
+
- `f.geo.multilinestring` (stored `geo_multilinestring`)
|
|
1394
|
+
- `f.geo.polygon` (stored `geo_polygon`)
|
|
1395
|
+
- `f.geo.multipolygon` (stored `geo_multipolygon`)
|
|
1396
|
+
- `f.enum`
|
|
1397
|
+
- `f.vector` — methods: max, min
|
|
1398
|
+
- `f.object` (stored `obj`)
|
|
1399
|
+
- `f.tableRef` (stored `int`) — methods: max, min
|
|
1400
|
+
|
|
1401
|
+
# Filter catalog
|
|
1402
|
+
|
|
1403
|
+
> Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
|
|
1404
|
+
|
|
1405
|
+
Attach to a value with `withFilters(v, fl.name(...))` — the value `filters[]`
|
|
1406
|
+
pipeline. Filters are passed spread (canonical); the array form
|
|
1407
|
+
`withFilters(v, [fl.a(), fl.b()])` is also accepted. Every filter is typed:
|
|
1408
|
+
the ones below carry named args, and the rest take NO arguments — call them
|
|
1409
|
+
`fl.<name>()`, and passing an argument is a compile error.
|
|
1410
|
+
A typed filter's declared argument list is an EXACT count, not a floor: passing more
|
|
1411
|
+
than it lists THROWS, in the type and at runtime. The extra argument used to ride into
|
|
1412
|
+
the filter's arg list, where the engine either ignores it or fails opaquely on a live
|
|
1413
|
+
endpoint. `filter("name", …)` is the untyped escape for a filter the catalog
|
|
1414
|
+
under-declares. Seven declared filters are exceptions the engine really does take more from, so they
|
|
1415
|
+
stay variadic: `concat` (trailing separator), `index_by` (trailing list flag), `get` (trailing variable
|
|
1416
|
+
map), `array_merge` and `array_merge_recursive` (any number of arrays), `jwe_encode` and
|
|
1417
|
+
`jwe_decode`. To send more than a zero-argument filter's `()` accepts, use the same
|
|
1418
|
+
`filter("name", …)` escape. A filter with NO declared list takes nothing and is emitted
|
|
1419
|
+
`()` — except the variadic few listed under the catalog, which take arguments the
|
|
1420
|
+
catalog never declared and so are not arity-checked at all.
|
|
1421
|
+
A typed filter also accepts one object of NAMED arguments (`fl.add({ value: 1 })`); a key
|
|
1422
|
+
the filter does not declare throws rather than being dropped.
|
|
1423
|
+
A bare JS **scalar** — string, number or boolean — is accepted in ANY `fl.*` argument and
|
|
1424
|
+
wrapped as the constant you would have written by hand (`fl.get("a.b", 0)` encodes
|
|
1425
|
+
identically to `fl.get(c.text("a.b"), c.int(0))`). An object or array must still be built
|
|
1426
|
+
with `c.obj`/`c.array`. Any argument also takes a runtime value (`inp`, `ref`, a
|
|
1427
|
+
`withFilters` chain) — inside `obj()`/`expr()` an argument cannot carry its own filter
|
|
1428
|
+
chain, so compute it in a prior `set_var`. The arg types below name each argument's
|
|
1429
|
+
ENGINE type, not the JS type you may pass.
|
|
1430
|
+
To add to a numeric column (counter, stock, balance), use `s.db.increment`
|
|
1431
|
+
(`llms/statements-data.md`) — one atomic UPDATE. A pipeline read-modify-write is NOT
|
|
1432
|
+
atomic (concurrent writers lose updates), and `col("clicks")` does NOT resolve to the
|
|
1433
|
+
stored value inside a `db.edit` `row` (it is `null`; `null + 1` aborts): `db.get` the row
|
|
1434
|
+
`as: "current"`, then `withFilters(ref("current.clicks"), fl.add(c.int(1)))`.
|
|
1435
|
+
|
|
1436
|
+
- `fl.add(value: decimal): decimal`
|
|
1437
|
+
- `fl.append(value: <T>, path: text): <T>[]`
|
|
1438
|
+
- `fl.array_diff(value: <T>[]): <T>[]`
|
|
1439
|
+
- `fl.array_diff_assoc(value: <T>[]): <T>[]`
|
|
1440
|
+
- `fl.array_fill(start: int, count: int): any[]`
|
|
1441
|
+
- `fl.array_fill_keys(keys: any[]): any[]`
|
|
1442
|
+
- `fl.array_filter(match: obj, path?: text): <T>[]` — matches by EXAMPLE (an object of key/value pairs), not by code, and compares LOOSELY
|
|
1443
|
+
- `fl.array_find(match: obj): <T>` — a MISS THROWS (`Unable to find element.`) rather than yielding null — use `array_has` to test first
|
|
1444
|
+
- `fl.array_has(match: obj): bool`
|
|
1445
|
+
- `fl.array_intersect(value: <T>[]): <T>[]`
|
|
1446
|
+
- `fl.array_intersect_assoc(value: <T>[]): <T>[]`
|
|
1447
|
+
- `fl.array_merge(value: <T>[]): <T>[]`
|
|
1448
|
+
- `fl.array_merge_recursive(value: <T>[]): <T>[]`
|
|
1449
|
+
- `fl.array_push(value: <T>): <T>[]`
|
|
1450
|
+
- `fl.array_push_conditional(value: any, conditional: bool): <T>[]`
|
|
1451
|
+
- `fl.array_remove(value: <T>, path: text, strict?: bool): <T>[]`
|
|
1452
|
+
- `fl.array_replace(match: any, result: any): <T>[]`
|
|
1453
|
+
- `fl.array_set(path: text, value: any): obj` — sets a DOTTED path and returns the value; a non-object piped value is replaced by an empty one
|
|
1454
|
+
- `fl.array_slice(offset: int, length?: int): <T>[]`
|
|
1455
|
+
- `fl.array_unshift(value: <T>): <T>[]`
|
|
1456
|
+
- `fl.array_upmerge(match: obj, node: obj): <T>[]` — merges the node INTO each matched element; `array_replace` swaps the element whole
|
|
1457
|
+
- `fl.base_convert(from_base: any, to_base: any): text`
|
|
1458
|
+
- `fl.bitwise_and(value: int): int`
|
|
1459
|
+
- `fl.bitwise_or(value: int): int`
|
|
1460
|
+
- `fl.bitwise_xor(value: int): int`
|
|
1461
|
+
- `fl.clamp_max(max: decimal): decimal`
|
|
1462
|
+
- `fl.clamp_min(min: decimal): decimal`
|
|
1463
|
+
- `fl.concat(value: any): text`
|
|
1464
|
+
- `fl.contains(search: text): bool` — piped value is the subject text; the arg is the substring searched for
|
|
1465
|
+
- `fl.convert_encoding(to: text, from: text): text`
|
|
1466
|
+
- `fl.create_object(values: <T>[]): any`
|
|
1467
|
+
- `fl.crypto_jwe_decode(check_claims: json, key: json, key_algorithm: enum, content_algorithm: enum, timeDrift?: int): json`
|
|
1468
|
+
- `fl.crypto_jwe_encode(headers: json, key: json, key_algorithm: enum, content_algorithm: enum, ttl?: int): text`
|
|
1469
|
+
- `fl.crypto_jws_decode(check_claims: json, key: json, algorithm: enum, timeDrift?: int): json`
|
|
1470
|
+
- `fl.crypto_jws_encode(headers: json, key: json, algorithm: enum, ttl?: int): text`
|
|
1471
|
+
- `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
|
|
1472
|
+
- `fl.csv_decode(separator: text, enclosure: text, escape: text): any`
|
|
1473
|
+
- `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
|
|
1474
|
+
- `fl.csv_parse(separator: text, enclosure: text, escape: text): any`
|
|
1475
|
+
- `fl.decrypt(algorithm: enum, key: text, iv: text): text`
|
|
1476
|
+
- `fl.detect_encoding(encodings?: text): text`
|
|
1477
|
+
- `fl.div(value: decimal): decimal`
|
|
1478
|
+
- `fl.encrypt(algorithm: enum, key: text, iv: text): text`
|
|
1479
|
+
- `fl.ends_with(search: text): bool` — piped value is the subject text; the arg is the substring searched for
|
|
1480
|
+
- `fl.epochms_add_ms(milliseconds: int): epochms`
|
|
1481
|
+
- `fl.epochms_add_secs(seconds: int): epochms`
|
|
1482
|
+
- `fl.epochms_date(format: text, timezone?: text): text`
|
|
1483
|
+
- `fl.epochms_from_format(format: text, timezone?: text): text`
|
|
1484
|
+
- `fl.epochms_transform(format: text, timezone?: text): text` — applies a relative shift (e.g. "+1 day") to the timestamp
|
|
1485
|
+
- `fl.eq(value: <T>): bool`
|
|
1486
|
+
- `fl.every(code: text, timeout?: int): any[]` — `code` is a JS body run per element (true for all?), over `$this`/`$index`/`$parent`
|
|
1487
|
+
- `fl.filter(code: text, timeout?: int): any[]` — `code` is a JS body run per element (keep it? true/false), over `$this`/`$index`/`$parent`
|
|
1488
|
+
- `fl.filter_empty(path?: text): <T>[]` — keeps entries that are not empty ("", null, 0, "0", false, [], {})
|
|
1489
|
+
- `fl.filter_empty_array(path?: text): <T>[]`
|
|
1490
|
+
- `fl.filter_empty_object(path?: text): <T>[]`
|
|
1491
|
+
- `fl.filter_empty_text(path?: text): <T>[]`
|
|
1492
|
+
- `fl.filter_false(path?: text): <T>[]`
|
|
1493
|
+
- `fl.filter_null(path?: text): <T>[]`
|
|
1494
|
+
- `fl.filter_zero(path?: text): <T>[]`
|
|
1495
|
+
- `fl.find(code: text, timeout?: int): any[]` — `code` is a JS body run per element; returns the first element it accepts
|
|
1496
|
+
- `fl.findIndex(code: text, timeout?: int): any[]` — `code` is a JS body run per element; returns the first matching index
|
|
1497
|
+
- `fl.first_notempty(value: any): any` — first value that is not empty ("", null, 0, "0", false, [], {})
|
|
1498
|
+
- `fl.first_notnull(value: any): any`
|
|
1499
|
+
- `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
|
|
1500
|
+
- `fl.get(path: text, default?: json): any` — `path` may be a runtime value: after `fl.index_by("suite_id")`, `fl.get(ref("suite.id"))` returns that key's rows (null for a missing key)
|
|
1501
|
+
- `fl.gt(value: <T>): bool`
|
|
1502
|
+
- `fl.gte(value: <T>): bool`
|
|
1503
|
+
- `fl.has(path: text): bool`
|
|
1504
|
+
- `fl.hmac_md5(key: text, raw?: bool): text`
|
|
1505
|
+
- `fl.hmac_sha1(key: text, raw?: bool): text`
|
|
1506
|
+
- `fl.hmac_sha256(key: text, raw?: bool): text`
|
|
1507
|
+
- `fl.hmac_sha384(key: text, raw?: bool): text`
|
|
1508
|
+
- `fl.hmac_sha512(key: text, raw?: bool): text`
|
|
1509
|
+
- `fl.icontains(search: text): bool` — case-insensitive; piped value is the subject, the arg is the substring
|
|
1510
|
+
- `fl.iends_with(search: text): bool` — case-insensitive; piped value is the subject, the arg is the substring
|
|
1511
|
+
- `fl.in(search: <T>): bool`
|
|
1512
|
+
- `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
|
|
1513
|
+
- `fl.istarts_with(search: text): bool` — case-insensitive; piped value is the subject, the arg is the substring
|
|
1514
|
+
- `fl.join(separator: text): text`
|
|
1515
|
+
- `fl.jwe_decode(arg1: any)`
|
|
1516
|
+
- `fl.jwe_encode(arg1: any)`
|
|
1517
|
+
- `fl.lambda(code: text, timeout?: int): any` — runs a JS body once over the piped value, which it binds as `$this` (NOT `$parent`)
|
|
1518
|
+
- `fl.log(base: <T>): decimal`
|
|
1519
|
+
- `fl.lt(value: <T>): bool`
|
|
1520
|
+
- `fl.lte(value: <T>): bool`
|
|
1521
|
+
- `fl.ltrim(mask?: text): text`
|
|
1522
|
+
- `fl.map(code: text, timeout?: int): any[]` — `code` is a JS body run per element, over `$this`/`$index`/`$parent` — build it with `lam.fn`
|
|
1523
|
+
- `fl.max(bound?: decimal): decimal`
|
|
1524
|
+
- `fl.md5(raw?: bool): text`
|
|
1525
|
+
- `fl.min(bound?: decimal): decimal`
|
|
1526
|
+
- `fl.mod(value: int): int`
|
|
1527
|
+
- `fl.mul(value: decimal): decimal`
|
|
1528
|
+
- `fl.ne(value: <T>): bool`
|
|
1529
|
+
- `fl.num_max(value: any): decimal`
|
|
1530
|
+
- `fl.num_min(value: any): decimal`
|
|
1531
|
+
- `fl.number_format(decimals: int, decimal_separator: text, thousands_separator: text): string`
|
|
1532
|
+
- `fl.pick(keys: text): <T>`
|
|
1533
|
+
- `fl.pow(exp: <T>): decimal`
|
|
1534
|
+
- `fl.prepend(value: <T>, path: text): <T>[]`
|
|
1535
|
+
- `fl.range(start: int, stop: int): int[]`
|
|
1536
|
+
- `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
|
|
1537
|
+
- `fl.regex_match(subject: text): text[]` — piped value is the PATTERN, the arg is the subject — see `regex_test`
|
|
1538
|
+
- `fl.regex_match_all(subject: text): text[]` — piped value is the PATTERN, the arg is the subject — see `regex_test`
|
|
1539
|
+
- `fl.regex_quote(delimiter?: text): text`
|
|
1540
|
+
- `fl.regex_replace(replacement: text, subject: text): text` — piped value is the PATTERN, `subject` is the text searched — see `regex_test`. The replacement comes FIRST
|
|
1541
|
+
- `fl.regex_test(subject: text): bool` — piped value is the PATTERN (build it with `c.regex(...)`); the arg is the subject — the REVERSE of `contains`/`starts_with`. Swapped, it answers false for every input with no error, so write `withFilters(c.regex("^a+$"), fl.regex_test(inp("s")))` (or name the arg: `fl.regex_test({ subject: inp("s") })`). A pattern found in the subject slot is refused at build time
|
|
1542
|
+
- `fl.round(precision?: int): decimal`
|
|
1543
|
+
- `fl.rtrim(mask?: text): text`
|
|
1544
|
+
- `fl.secureid_decode(salt: text): int`
|
|
1545
|
+
- `fl.secureid_encode(salt: text): text`
|
|
1546
|
+
- `fl.set(path: text, value: any): any`
|
|
1547
|
+
- `fl.set_conditional(path: text, value: any, conditional: any): any`
|
|
1548
|
+
- `fl.set_ifnotempty(path: text, value: any): any`
|
|
1549
|
+
- `fl.set_ifnotnull(path: text, value: any): any`
|
|
1550
|
+
- `fl.sha1(raw?: bool): text`
|
|
1551
|
+
- `fl.sha256(raw?: bool): text`
|
|
1552
|
+
- `fl.sha384(raw?: bool): text`
|
|
1553
|
+
- `fl.sha512(raw?: bool): text`
|
|
1554
|
+
- `fl.some(code: text, timeout?: int): any[]` — `code` is a JS body run per element (true for any?), over `$this`/`$index`/`$parent`
|
|
1555
|
+
- `fl.splice(offset: int, length?: int, path?: text): <T>[]` — returns the REMOVED slice, not the remainder — [1,2,3,4] spliced at offset 1 length 2 yields [2,3]; with a `path` it removes in place and returns the whole value instead
|
|
1556
|
+
- `fl.split(separator: text): text[]`
|
|
1557
|
+
- `fl.starts_with(search: text): bool` — piped value is the subject text; the arg is the substring searched for
|
|
1558
|
+
- `fl.string_replace(search: text, replacement: text): text`
|
|
1559
|
+
- `fl.strip_tags(exclude?: text): text`
|
|
1560
|
+
- `fl.stripos(search: text): int`
|
|
1561
|
+
- `fl.strpos(search: text): int`
|
|
1562
|
+
- `fl.sub(value: decimal): decimal`
|
|
1563
|
+
- `fl.substr(start: int, length: int): text`
|
|
1564
|
+
- `fl.to_epoch_day(timezone?: text): int`
|
|
1565
|
+
- `fl.to_epoch_hour(timezone?: text): int`
|
|
1566
|
+
- `fl.to_epoch_minute(timezone?: text): int`
|
|
1567
|
+
- `fl.to_epoch_ms(timezone?: text): int`
|
|
1568
|
+
- `fl.to_epoch_sec(timezone?: text): int`
|
|
1569
|
+
- `fl.to_epochms(timezone?: text): epochms`
|
|
1570
|
+
- `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`
|
|
1571
|
+
- `fl.trim(mask?: text): text`
|
|
1572
|
+
- `fl.unique(path?: text): <T>[]`
|
|
1573
|
+
- `fl.unpick(keys: text): <T>` — returns the object without the named keys (inverse of a pick)
|
|
1574
|
+
- `fl.unset(path: text): any`
|
|
1575
|
+
- `fl.url_addarg(key: text, value: text, encoding_rfc3986?: bool): text`
|
|
1576
|
+
- `fl.url_delarg(key: text): text`
|
|
1577
|
+
- `fl.url_getarg(key: text, default?: text): text`
|
|
1578
|
+
- `fl.url_hasarg(key: text): text`
|
|
1579
|
+
- `fl.yaml_encode(options?: bool, inline?: int, indent?: int): text`
|
|
1580
|
+
|
|
1581
|
+
Zero-argument filters — call as `fl.<name>()`; an argument is a compile error: abs, acos, acosh, addslashes, array_entries, array_keys, array_lower, 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, ms_to_secs, not, null, octdec, odd, product, querystring_parse, rad2deg, reverse, rsort, safe_array, secs_to_ms, sin, sort, sql_alias, sql_esc, sqrt, strip_accents, strlen, sum, tan, text_escape, text_unescape, to_bool, to_decimal, to_expr, to_geo_linestring, to_geo_multilinestring, to_geo_multipoint, to_geo_multipolygon, to_geo_point, to_geo_polygon, 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.
|
|
1582
|
+
|
|
1583
|
+
Variadic filters — they take arguments, but no declared list, so the count is not checked: sprintf.
|
|
1584
|
+
|
|
1585
|
+
# Lambda bodies (JavaScript)
|
|
1586
|
+
|
|
1587
|
+
> Read when writing a JavaScript body, or weighing whether to reach for one at all — `lam.fn`, `fl.lambda`, `fl.reduce`, `s.lambda`; each surface binds a different set of identifiers.
|
|
1588
|
+
|
|
1589
|
+
**A lambda is an escape hatch, not a default.** The body runs outside the request's own
|
|
1590
|
+
runtime, and a workspace has a BOUNDED pool of lambda workers every lambda in it shares
|
|
1591
|
+
— so a call both crosses a process boundary and draws on a workspace-wide resource.
|
|
1592
|
+
Reach for one only when the typed surface cannot express the work: if a native filter,
|
|
1593
|
+
an `expr(...)`/`obj(...)` expression, or a plain statement can, use that. The crossing
|
|
1594
|
+
is per CALL, not per element — an iterating filter sends the body ONCE and loops on the
|
|
1595
|
+
other side, so one body over a whole list beats one called from inside a stack loop.
|
|
1596
|
+
|
|
1597
|
+
The lambda statement (`s.lambda({ as, code, timeout? })`) and eight filters run a
|
|
1598
|
+
JavaScript body. **Write the body as a FUNCTION, not a `c.text` string** — the
|
|
1599
|
+
bindings are its parameters, so the editor supplies them and a wrong name is a
|
|
1600
|
+
compile error instead of a wrong value at runtime. Write it inline and the surface
|
|
1601
|
+
is implied by where it sits; nothing names one:
|
|
1602
|
+
|
|
1603
|
+
```ts
|
|
1604
|
+
fl.map(({ $this }) => $this * 2) // map's bindings, typed from the position
|
|
1605
|
+
fl.reduce({ initial_value: 0, code: ({ $result, $this }) => $result + $this })
|
|
1606
|
+
s.lambda({ as: "total", code: ({ $var }) => $var.subtotal * 1.2 }) // ambient only — $this is a compile error
|
|
1607
|
+
```
|
|
1608
|
+
|
|
1609
|
+
The parameters are a fiction — only the BODY is sent, and the engine injects the
|
|
1610
|
+
bindings as free identifiers — so DESTRUCTURE them as named: `(b) => b.$this`,
|
|
1611
|
+
`{ $this: x }`, nesting, a default or rest are undefined there (refused).
|
|
1612
|
+
|
|
1613
|
+
⚠ An inline `code:` arrow receives BINDINGS ONLY. `capture` is an option of
|
|
1614
|
+
`lam.fn`, not a field of `s.lambda` or a filter, so `capture:` beside `code:` is a type
|
|
1615
|
+
error — the fix is to relocate the body, not to drop the field: `lam.fn(fn, { capture })` (below).
|
|
1616
|
+
|
|
1617
|
+
For a body built away from its call site:
|
|
1618
|
+
|
|
1619
|
+
- `lam.fn(({ $result, $this }) => $result + $this, { surface?, capture? })` — name a `surface` to check it here, or omit it and the call site checks it.
|
|
1620
|
+
- `lam.raw("return 1", { surface })` — text, same validation.
|
|
1621
|
+
- `lam.file("./lambdas/total.ts")` — a default-exported function in its own module (path relative to the caller), read as text at build time. NODE ONLY: `import { lam } from "@xano/sdk/node"` (isomorphic `lam` has no `file`). Only the default export's BODY is sent, so a value import, a second export or a top-level helper is refused — move helpers inside; `import type` / `import { type X }` are free. `@xano/sdk/lambda-globals` types the globals below program-wide: keep modules in `xano/lambdas/` (its scaffold tsconfig loads it) or `import type {} from` it under their own tsconfig.
|
|
1622
|
+
|
|
1623
|
+
Nothing from the enclosing scope crosses: the body is sent as TEXT, so a closed-over
|
|
1624
|
+
`const rate` is undefined there (a wrong VALUE at HTTP 200, not an error). Put what the
|
|
1625
|
+
body needs in `capture`; it arrives as the SECOND parameter, emitted as a `const` prelude:
|
|
1626
|
+
|
|
1627
|
+
```ts
|
|
1628
|
+
lam.fn(({ $this }, { capturedRate }) => $this * capturedRate, { surface: "map", capture: { capturedRate: rate } })
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
⚠ A capture key must NOT share its name with a module-scope binding: a `.ts` loader
|
|
1632
|
+
renames one of two same-named bindings, so the body reads `rate2` while the prelude
|
|
1633
|
+
declares `rate` (refused at build time). `capture: { capturedRate: rate }` is the safe form.
|
|
1634
|
+
|
|
1635
|
+
Capture JSON data only: a function, `NaN`, a sparse/typed array or class instance (`Date`, `Map`…)
|
|
1636
|
+
is refused at build time, at any depth. Capture the plain form and rebuild in
|
|
1637
|
+
the body (`d.getTime()` → `new Date(d)`). The captured type flows into the second parameter.
|
|
1638
|
+
|
|
1639
|
+
A body is a FUNCTION BODY: it must `return` its value. Bindings by surface — an
|
|
1640
|
+
identifier outside its surface's set is undefined at runtime, and the SDK refuses
|
|
1641
|
+
it at build time whichever spelling you use:
|
|
1642
|
+
|
|
1643
|
+
- every surface: `$env` · `$input` · `$var` · `$auth` (+ the `console` / `crypto` globals)
|
|
1644
|
+
- `fl.lambda`: + `$this`
|
|
1645
|
+
- `fl.map` · `fl.filter` · `fl.some` · `fl.every` · `fl.find` · `fl.findIndex`: + `$this` · `$index` · `$parent`
|
|
1646
|
+
- `fl.reduce`: + `$this` · `$index` · `$parent` · `$result`
|
|
1647
|
+
- `s.lambda`: ambient only — no `$this`, no `$parent`, no `$result`.
|
|
1648
|
+
|
|
1649
|
+
`$result` is `reduce`'s ACCUMULATOR (there is no `$acc`). `$this` is the element in
|
|
1650
|
+
an iterating filter and the piped value in `fl.lambda`; `$parent` is the whole array
|
|
1651
|
+
and exists only on the iterating filters. A stack variable is reached as
|
|
1652
|
+
`$var.name` — it is NOT also injected as a bare `$name`.
|
|
1653
|
+
|
|
1654
|
+
Four hazards and the dependency route, all live-verified:
|
|
1655
|
+
|
|
1656
|
+
- ⚠ A body that THROWS does not fail the request: the engine returns its diagnostic
|
|
1657
|
+
TEXT as the value with HTTP 200, so the failure reads as bad data. Validate before
|
|
1658
|
+
consuming a lambda result numerically, and prefer a `lam.*` body, which cannot fail
|
|
1659
|
+
this way for a binding reason.
|
|
1660
|
+
- ⚠ `timeout` is COOPERATIVE — observed only at an `await` — so it bounds WAITING (a slow
|
|
1661
|
+
`fetch`), not compute: a 1s `timeout` over a 3s busy-loop runs all 3s and returns
|
|
1662
|
+
normally. Bound a loop that could run away inside the body.
|
|
1663
|
+
- ⚠ A top-level `import`/`export` is a syntax error — the body is a function body. Reach
|
|
1664
|
+
a dependency through the PRELOADED globals below. A dynamic `import("…")`/`require("…")`
|
|
1665
|
+
with a LITERAL specifier is not portable: an instance that bundles the body first
|
|
1666
|
+
returns the TEXT `Could not resolve "node:crypto"` with HTTP 200.
|
|
1667
|
+
- Preloaded globals, live-probed — no specifier, so these work everywhere:
|
|
1668
|
+
`_` · `aws4` · `axios` · `cryptojs` · `DateTime` · `ethers` · `fastXmlParser` ·
|
|
1669
|
+
`jose` · `luxon` · `mailparser` · `math` · `moment` · `nodemailer` · `socks` ·
|
|
1670
|
+
`uuid` · `utils`
|
|
1671
|
+
…plus `fetch`, `Buffer`, `TextEncoder`/`TextDecoder`, and the `crypto` above
|
|
1672
|
+
(`randomUUID`, `createHmac`, `createHash`, `subtle`). `Object.keys(globalThis)`
|
|
1673
|
+
inside a body lists whatever else a given instance carries.
|
|
1674
|
+
- ⚠ `console` output goes to the request LOG, not stdout. `log` · `error` · `warn` ·
|
|
1675
|
+
`info` · `debug` · `trace` all route there; any other `console` method is undefined,
|
|
1676
|
+
and CALLING it throws (error text as the value, HTTP 200).
|
|
1677
|
+
|
|
1678
|
+
TypeScript annotations survive in the body, and top-level `await` works.
|
|
1679
|
+
|
|
1680
|
+
# Lock file
|
|
1681
|
+
|
|
1682
|
+
> Read when a `xano.lock` exists or should — renaming/pruning/importing identities, seeding the lock programmatically, or asking which commands write it.
|
|
1683
|
+
|
|
1684
|
+
`xano.lock` pins each object's guid and each api-group/toolset canonical, so renames
|
|
1685
|
+
stay renames (guids otherwise derive from `(type, name)`; a query's from `(api group,
|
|
1686
|
+
verb, name)`). Committed. A composed entry is keyed by that identity —
|
|
1687
|
+
`query:<group>|<verb>|<name>`, `channel:<server>|<path>`, `message:<server>|<path>|<name>`
|
|
1688
|
+
— its guid's seed; a legacy `query:<name>` is migrated by the next locked export.
|
|
1689
|
+
|
|
1690
|
+
- `xanosdk lock rename <kind> <old> <new>` — `kind` is the SDK kind (`table`, `apiGroup`) or stored key (`dbo`).
|
|
1691
|
+
Run it when `export` names it after a rename in code (it prints the command); the next export emits the original guid under the new name.
|
|
1692
|
+
Reads the `index.ts` beside the lock (or `--entry`); refused while `<old>` is still exported. Composed names go in full; renaming a parent moves them.
|
|
1693
|
+
- `xanosdk lock prune <entry-file> [keys…] --yes` — drops orphaned entries. Finding orphans
|
|
1694
|
+
RUNS the entry's module scope (env assertions included); `--identity-only --yes <key>…`
|
|
1695
|
+
prunes named keys (`table:users`) with no evaluation and no orphan check.
|
|
1696
|
+
- `xanosdk lock import <live-bundle.json> [--yes]` — seed the lock from a `workspace export`
|
|
1697
|
+
to take over an existing workspace (a compiled bundle is refused).
|
|
1698
|
+
- Every lock subcommand accepts `--lock=<path>`; from outside the lock's directory pass it.
|
|
1699
|
+
- Merge conflict: keep both sides; two keys on one guid → `lock rename` onto the kept
|
|
1700
|
+
name, or `lock prune --identity-only --yes` the other.
|
|
1701
|
+
- Programmatic use: call `seedLockOverrides(readLockFile(path))` BEFORE importing any def
|
|
1702
|
+
module — references bake guids at import time; `writeBundle` refuses an unseeded lock.
|
|
1703
|
+
|
|
1704
|
+
What writes the lock: `export`/`deploy`/`release`/`preflight` of an ENTRY FILE update it;
|
|
1705
|
+
`compile`/`paths` only READ. `--no-lock` builds
|
|
1706
|
+
without one (names derive guids, the instance invents public URLs, no `--prune`);
|
|
1707
|
+
REFUSED over a parseable lock. A DEPLOY or a RELEASE writes back only `landed`: what
|
|
1708
|
+
this project LANDED on each destination (ephemerals: `.xano/ephemeral.json`). `--prune` deletes only that — never a merely
|
|
1709
|
+
exported name; no record there, no prune until one landing (commit it). A `deploy` (`--replace`
|
|
1710
|
+
too) PRESERVES the archive's guids, so the lock still describes the rebuilt
|
|
1711
|
+
workspace. CI: `--frozen-lock` fails (`SDK_DRIFT`) instead of changing the lock, and fails while the lock
|
|
1712
|
+
carries an entry no exported object matches — a plain `export` only warns and writes that
|
|
1713
|
+
entry down, after which nothing tells a rename from a deletion: resolve it with
|
|
1714
|
+
`lock rename` or `lock prune`, or accept them with `--allow-lock-orphans` when they are the
|
|
1715
|
+
not-yet-ported half of an adopted workspace.
|
|
1716
|
+
`export --check` implies `--frozen-lock` and writes nothing — no bundle, no lock — so it
|
|
1717
|
+
reads no `xano/.env` or `.secrets.json` and refuses the flags that supply them; this is
|
|
1718
|
+
what `npm run xano:check` runs, so CI needs no secrets. With no lock at all, either flag
|
|
1719
|
+
fails only when the source has identities to record (an empty workspace passes).
|
|
1720
|
+
|
|
1721
|
+
# Legacy paradigms and retired statements
|
|
1722
|
+
|
|
1723
|
+
> Read when the code was PULLED from an existing Xano instance — how a codegen'd tree reads, the shapes in it that must not be "fixed", and any `raw({ name: "mvp:…" })`, `realtimeTrigger()`, or name the catalogs do not list.
|
|
1724
|
+
|
|
1725
|
+
Older paradigms this SDK still supports and still emits when it decodes an existing
|
|
1726
|
+
workspace. **Do not author these.** They are listed by name only so you recognize them
|
|
1727
|
+
in pulled code rather than "fixing" them; each line names what to use instead.
|
|
1728
|
+
|
|
1729
|
+
Names overlap across the split deliberately — the engine reused words like
|
|
1730
|
+
"realtime" and "channel" for both generations. A name matching is NOT evidence that
|
|
1731
|
+
two things are the same object; check which list it came from.
|
|
1732
|
+
|
|
1733
|
+
- `c.expressionLegacy` — the older `const:expr` expression form, emitted by codegen for workspaces that still hold one — author `c.expression` instead.
|
|
1734
|
+
- `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).
|
|
1735
|
+
- `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()`.
|
|
1736
|
+
|
|
1737
|
+
Retired statement VERSIONS — no `s.` surface exists. Pulled code shows them as
|
|
1738
|
+
`raw({ name: "…" })` and they keep running as stored, so leave them; author the
|
|
1739
|
+
replacement only for NEW code. Never swap one for the other — each version broke the last.
|
|
1740
|
+
|
|
1741
|
+
- `mvp:crypto_jwe_decode` → `mvp:crypto_jwe_decode2`
|
|
1742
|
+
- `mvp:crypto_jwe_encode` → `mvp:crypto_jwe_encode3`
|
|
1743
|
+
- `mvp:crypto_jwe_encode2` → `mvp:crypto_jwe_encode3`
|
|
1744
|
+
- `mvp:crypto_jws_decode` → `mvp:crypto_jws_decode2`
|
|
1745
|
+
- `mvp:crypto_jws_encode` → `mvp:crypto_jws_encode2`
|
|
1746
|
+
- `mvp:connect_ncscale_send_log` — retired, no replacement
|
|
1747
|
+
|
|
1748
|
+
Statements the engine writes but will NOT import back — no `s.` surface exists, and
|
|
1749
|
+
unlike the retired versions above these must be FIXED, not left alone. Pulled code shows
|
|
1750
|
+
them as `raw({ name: "…" })`; `export()` refuses any bundle that still contains one.
|
|
1751
|
+
|
|
1752
|
+
- `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.
|
|
1753
|
+
|
|
1754
|
+
## Reading a pulled tree
|
|
1755
|
+
|
|
1756
|
+
`codegen` emits objects as FACTORY calls (`table({...})`, `query({...})`, …) — the form
|
|
1757
|
+
you author by hand — so inference survives the round trip.
|
|
1758
|
+
Three shapes read differently and must not be "fixed": a trigger its factory cannot
|
|
1759
|
+
reproduce (its target outside the pulled tree, or `meta` it cannot write) stays
|
|
1760
|
+
`{...} satisfies TriggerDef`; a
|
|
1761
|
+
statement the catalog cannot model round-trips verbatim through `raw()`
|
|
1762
|
+
(`@xano/sdk/codegen`); and an object ALREADY EMPTY upstream decodes to a def with
|
|
1763
|
+
no `stack`, reported as `empty-source` — faithful, not a decode failure. Workspace env
|
|
1764
|
+
var VALUES go to `xano/.env` (owner-only, gitignored); `xano/workspace.ts` declares each as `""`.
|
|
1765
|
+
|
|
1766
|
+
# Statement catalog
|
|
1767
|
+
|
|
1768
|
+
> Read for the field signature of a specific statement — every surface, grouped by `s.*` namespace.
|
|
1769
|
+
|
|
1770
|
+
### (top-level)
|
|
1771
|
+
|
|
1772
|
+
`s.throw` raises a named error `s.try_catch` catches; uncaught, it answers HTTP 200 `{ payload, statement: "Throw Error" }` (`s.precondition` sets a status). Inside `s.db.transaction` a throw or failed precondition rolls it back.
|
|
1773
|
+
|
|
1774
|
+
- `s.await({ as?: string; ids?: value; timeout?: value })`
|
|
1775
|
+
- `s.foreach_break` [special]
|
|
1776
|
+
- `s.comment` [special]
|
|
1777
|
+
- `s.conditional` [special]
|
|
1778
|
+
- `s.foreach_continue` [special]
|
|
1779
|
+
- `s.for` [special]
|
|
1780
|
+
- `s.foreach_remove` [special]
|
|
1781
|
+
- `s.foreach` [special]
|
|
1782
|
+
- `s.group` [special]
|
|
1783
|
+
- `s.lambda({ as?: string; code: value; timeout?: value })`
|
|
1784
|
+
- `s.precondition({ expr?: comparison; error_type?: "standard" | "notfound" | "toomanyrequests" | "accessdenied" | "unauthorized" | "badrequest" | "inputerror"; error?: value; payload?: value })`
|
|
1785
|
+
- `s.return` [special]
|
|
1786
|
+
- `s.switch` [special]
|
|
1787
|
+
- `s.throw({ name?: string; value: value })`
|
|
1788
|
+
- `s.try_catch` [special]
|
|
1789
|
+
- `s.update_var` [special]
|
|
1790
|
+
- `s.set_var` [special]
|
|
1791
|
+
- `s.while` [special]
|
|
1792
|
+
|
|
1793
|
+
### action
|
|
1794
|
+
|
|
1795
|
+
- `s.action.call` [special]
|
|
1796
|
+
- `s.action.package.call` [special]
|
|
1797
|
+
|
|
1798
|
+
### addon
|
|
1799
|
+
|
|
1800
|
+
- `s.addon.call` [special]
|
|
1801
|
+
|
|
1802
|
+
### ai
|
|
1803
|
+
|
|
1804
|
+
- `s.ai.agent.run` [special]
|
|
1805
|
+
- `s.ai.external.mcp.server_details({ as?: string; url: value; bearer_token?: value; connection_type?: "sse" | "stream" })`
|
|
1806
|
+
- `s.ai.external.mcp.tool.list({ as?: string; url: value; bearer_token?: value; connection_type?: "sse" | "stream" })`
|
|
1807
|
+
- `s.ai.external.mcp.tool.run({ as?: string; url: value; bearer_token?: value; connection_type?: "sse" | "stream"; tool: value; args?: value })`
|
|
1808
|
+
|
|
1809
|
+
### api
|
|
1810
|
+
|
|
1811
|
+
- `s.api.call` [special]
|
|
1812
|
+
- `s.api.request` [special, output]
|
|
1813
|
+
- `s.api.stream({ value: value })`
|
|
1814
|
+
|
|
1815
|
+
### array
|
|
1816
|
+
|
|
1817
|
+
- `s.array.difference({ as?: string; expr?: value; value?: value; by?: value })`
|
|
1818
|
+
- `s.array.every({ expr?: value; as?: string; if?: comparison })` [output] → as: boolean
|
|
1819
|
+
- `s.array.filter_count({ expr?: value; as?: string; if?: comparison })`
|
|
1820
|
+
- `s.array.filter({ expr?: value; as?: string; if?: comparison })`
|
|
1821
|
+
- `s.array.find_index({ expr?: value; as?: string; if?: comparison })`
|
|
1822
|
+
- `s.array.find({ expr?: value; as?: string; if?: comparison })` [output]
|
|
1823
|
+
- `s.array.group_by({ as?: string; expr?: value; by?: value })`
|
|
1824
|
+
- `s.array.has({ expr?: value; as?: string; if?: comparison })`
|
|
1825
|
+
- `s.array.intersection({ as?: string; expr?: value; value?: value; by?: value })`
|
|
1826
|
+
- `s.array.map` [special]
|
|
1827
|
+
- `s.array.merge({ name?: string; value?: value })` [output]
|
|
1828
|
+
- `s.array.partition({ expr?: value; as?: string; if?: comparison })`
|
|
1829
|
+
- `s.array.pop({ name?: string; as?: string })` [output]
|
|
1830
|
+
- `s.array.push({ name?: string; value: value })` [output]
|
|
1831
|
+
- `s.array.shift({ name?: string; as?: string })`
|
|
1832
|
+
- `s.array.union` [special]
|
|
1833
|
+
- `s.array.unshift({ name?: string; value: value })`
|
|
1834
|
+
|
|
1835
|
+
### cloud
|
|
1836
|
+
|
|
1837
|
+
- `s.cloud.algolia.request({ as?: string; application_id: value; api_key: value; url: value; method?: "POST" | "GET" | "DELETE" | "PUT"; payload: value })` [output]
|
|
1838
|
+
- `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 })`
|
|
1839
|
+
- `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 })`
|
|
1840
|
+
- `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 })`
|
|
1841
|
+
- `s.cloud.aws.s3.delete_file({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value })`
|
|
1842
|
+
- `s.cloud.aws.s3.get_file_info({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value })`
|
|
1843
|
+
- `s.cloud.aws.s3.list_directory({ as?: string; bucket: value; region: value; key: value; secret: value; prefix?: value; next_page_token?: value })`
|
|
1844
|
+
- `s.cloud.aws.s3.read_file({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value })`
|
|
1845
|
+
- `s.cloud.aws.s3.sign_url({ as?: string; bucket: value; region: value; key: value; secret: value; file_key: value; ttl?: value })`
|
|
1846
|
+
- `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 })`
|
|
1847
|
+
- `s.cloud.azure.storage.delete_file({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value })`
|
|
1848
|
+
- `s.cloud.azure.storage.get_file_info({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value })`
|
|
1849
|
+
- `s.cloud.azure.storage.list_directory({ as?: string; account_name: value; account_key: value; container_name: value; path?: value })`
|
|
1850
|
+
- `s.cloud.azure.storage.read_file({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value })`
|
|
1851
|
+
- `s.cloud.azure.storage.sign_url({ as?: string; account_name: value; account_key: value; container_name: value; path: value; ttl?: value })`
|
|
1852
|
+
- `s.cloud.azure.storage.upload_file({ as?: string; account_name: value; account_key: value; container_name: value; filePath: value; file: value; metadata?: value })`
|
|
1853
|
+
- `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 })`
|
|
1854
|
+
- `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 })`
|
|
1855
|
+
- `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 })`
|
|
1856
|
+
- `s.cloud.google.storage.delete_file({ as?: string; service_account: value; bucket: value; filePath: value })`
|
|
1857
|
+
- `s.cloud.google.storage.get_file_info({ as?: string; service_account: value; bucket: value; filePath: value })`
|
|
1858
|
+
- `s.cloud.google.storage.list_directory({ as?: string; service_account: value; bucket: value; path: value })`
|
|
1859
|
+
- `s.cloud.google.storage.read_file({ as?: string; service_account: value; bucket: value; filePath: value })`
|
|
1860
|
+
- `s.cloud.google.storage.sign_url({ as?: string; service_account: value; bucket: value; filePath: value; method?: "GET" | "POST"; ttl?: value })`
|
|
1861
|
+
- `s.cloud.google.storage.upload_file({ as?: string; service_account: value; bucket: value; filePath: value; file: value; metadata?: value })`
|
|
1862
|
+
- `s.cloud.job.await` [special]
|
|
1863
|
+
- `s.cloud.job.status` [special]
|
|
1864
|
+
- `s.cloud.job` [special]
|
|
1865
|
+
|
|
1866
|
+
### datadog
|
|
1867
|
+
|
|
1868
|
+
- `s.datadog.log_bulk({ entries: value; connection?: value })`
|
|
1869
|
+
- `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 })`
|
|
1870
|
+
- `s.datadog.metric_bulk({ entries: value; connection?: value })`
|
|
1871
|
+
- `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 })`
|
|
1872
|
+
|
|
1873
|
+
### db
|
|
1874
|
+
|
|
1875
|
+
- `s.db.add_or_edit` [special] → as: InferRow<T> (upserts and never misses)
|
|
1876
|
+
- `s.db.add` [special] → as: InferRow<T> (the full inserted row incl. id/created_at)
|
|
1877
|
+
- `s.db.bulk.add` [special]
|
|
1878
|
+
- `s.db.bulk.delete` [special] → as: number (count of deleted rows)
|
|
1879
|
+
- `s.db.bulk.patch` [special] → as: InferRow<T>[]
|
|
1880
|
+
- `s.db.bulk.update` [special]
|
|
1881
|
+
- `s.db.del` [special] → as: null (the engine deletes and returns no value; throws NotFound on a miss)
|
|
1882
|
+
- `s.db.direct_query` [special]
|
|
1883
|
+
- `s.db.edit` [special] → as: InferRow<T> (the full post-mutation row; throws NotFound on a miss)
|
|
1884
|
+
- `s.db.external.mssql.direct_query` [special]
|
|
1885
|
+
- `s.db.external.mysql.direct_query` [special]
|
|
1886
|
+
- `s.db.external.oracle.direct_query` [special]
|
|
1887
|
+
- `s.db.external.postgres.direct_query` [special]
|
|
1888
|
+
- `s.db.external.snowflake.direct_query` [special]
|
|
1889
|
+
- `s.db.get` [special] → as: InferRow<T> | null (binds null on a miss, never throws)
|
|
1890
|
+
- `s.db.has` [special] → as: boolean
|
|
1891
|
+
- `s.db.increment` [special] → as: InferRow<T>[] | number (the updated rows, or the changed-row count with returnType "count")
|
|
1892
|
+
- `s.db.patch` [special] → as: InferRow<T> (the full post-mutation row; throws NotFound on a miss)
|
|
1893
|
+
- `s.db.query` [special] → as: InferRow<T>[] (a paging envelope when metadata paging is on)
|
|
1894
|
+
- `s.db.schema` [special]
|
|
1895
|
+
- `s.db.set_datasource({ value: value; workspace_id?: value })`
|
|
1896
|
+
- `s.db.transaction` [special]
|
|
1897
|
+
- `s.db.truncate` [special]
|
|
1898
|
+
|
|
1899
|
+
### debug
|
|
1900
|
+
|
|
1901
|
+
`s.debug.stop` is a REAL terminator despite the namespace: it ends the request with an error value. Also exported flat as `die()`; there is no `s.die`.
|
|
1902
|
+
|
|
1903
|
+
- `s.debug.log({ value: value })`
|
|
1904
|
+
- `s.debug.stop({ value: value })`
|
|
1905
|
+
|
|
1906
|
+
### expect
|
|
1907
|
+
|
|
1908
|
+
Assertions. **Put them in a `workflowTest({...})` stack** — assert on what a `.call` bound with `as`. They are NOT inert elsewhere: a failure aborts the stack, so an `s.expect.*` in a `query`/`function`/`task` fails the request with HTTP 500 and the assertion's message (`to_equal failed - expected value 2 does not equal 1`) — remove it. `to_throw` sees only an error carrying a MESSAGE, so an empty-message failure (`ERROR_CODE_ACCESS_DENIED`) reports "response is ok", and `to_be_within` EXCLUDES both bounds — `min < expr < max` — while `s.security.random_number`'s are inclusive.
|
|
1909
|
+
|
|
1910
|
+
- `s.expect.to_be_defined({ expr?: value })`
|
|
1911
|
+
- `s.expect.to_be_empty({ expr?: value })`
|
|
1912
|
+
- `s.expect.to_be_false({ expr?: value })`
|
|
1913
|
+
- `s.expect.to_be_greater_than({ expr?: value; value?: value })`
|
|
1914
|
+
- `s.expect.to_be_in_the_future({ expr?: value })`
|
|
1915
|
+
- `s.expect.to_be_in_the_past({ expr?: value })`
|
|
1916
|
+
- `s.expect.to_be_less_than({ expr?: value; value?: value })`
|
|
1917
|
+
- `s.expect.to_be_null({ expr?: value })`
|
|
1918
|
+
- `s.expect.to_be_true({ expr?: value })`
|
|
1919
|
+
- `s.expect.to_be_within({ expr?: value; min?: value; max?: value })`
|
|
1920
|
+
- `s.expect.to_contain({ expr?: value; value?: value })`
|
|
1921
|
+
- `s.expect.to_end_with({ expr?: value; value?: value })`
|
|
1922
|
+
- `s.expect.to_equal({ expr?: value; value?: value })`
|
|
1923
|
+
- `s.expect.to_match({ expr?: value; value?: value })`
|
|
1924
|
+
- `s.expect.to_not_be_defined({ expr?: value })`
|
|
1925
|
+
- `s.expect.to_not_be_null({ expr?: value })`
|
|
1926
|
+
- `s.expect.to_not_equal({ expr?: value; value?: value })`
|
|
1927
|
+
- `s.expect.to_start_with({ expr?: value; value?: value })`
|
|
1928
|
+
- `s.expect.to_throw` [special]
|
|
1929
|
+
|
|
1930
|
+
### function
|
|
1931
|
+
|
|
1932
|
+
- `s.function.call` [special]
|
|
1933
|
+
- `s.function.run` [special]
|
|
1934
|
+
|
|
1935
|
+
### math
|
|
1936
|
+
|
|
1937
|
+
- `s.math.add({ name?: string; value: value })` [output] → as: number
|
|
1938
|
+
- `s.math.bitwise.and({ name?: string; value: value })` [output] → as: number
|
|
1939
|
+
- `s.math.bitwise.or({ name?: string; value: value })` [output] → as: number
|
|
1940
|
+
- `s.math.bitwise.xor({ name?: string; value: value })` [output] → as: number
|
|
1941
|
+
- `s.math.div({ name?: string; value: value })`
|
|
1942
|
+
- `s.math.mod({ name?: string; value: value })`
|
|
1943
|
+
- `s.math.mul({ name?: string; value: value })`
|
|
1944
|
+
- `s.math.sub({ name?: string; value: value })`
|
|
1945
|
+
|
|
1946
|
+
### mcp
|
|
1947
|
+
|
|
1948
|
+
- `s.mcp.elicit` [special]
|
|
1949
|
+
- `s.mcp.oauth.complete({ as?: string; request: value; decision: "approve" | "deny"; unsafe_user_id?: value })`
|
|
1950
|
+
- `s.mcp.oauth.request({ as?: string; request: value })`
|
|
1951
|
+
- `s.mcp.oauth.revoke({ as?: string; mcp_server: value; user_id?: value; grant_id?: value; all?: value })`
|
|
1952
|
+
- `s.mcp.progress({ progress: value; total?: value; message?: value })`
|
|
1953
|
+
|
|
1954
|
+
### microservice
|
|
1955
|
+
|
|
1956
|
+
- `s.microservice.request` [special]
|
|
1957
|
+
|
|
1958
|
+
### middleware
|
|
1959
|
+
|
|
1960
|
+
- `s.middleware.call` [special]
|
|
1961
|
+
|
|
1962
|
+
### object
|
|
1963
|
+
|
|
1964
|
+
- `s.object.entries({ as?: string; value?: value })` [output] → as: [string, unknown][]
|
|
1965
|
+
- `s.object.keys({ as?: string; value?: value })` [output] → as: string[]
|
|
1966
|
+
- `s.object.values({ as?: string; value?: value })` [output] → as: unknown[]
|
|
1967
|
+
|
|
1968
|
+
### realtime
|
|
1969
|
+
|
|
1970
|
+
- `s.realtime.get_session({ as?: string })`
|
|
1971
|
+
- `s.realtime.publish` [special]
|
|
1972
|
+
|
|
1973
|
+
### redis
|
|
1974
|
+
|
|
1975
|
+
- `s.redis.count({ as?: string; key: value })`
|
|
1976
|
+
- `s.redis.decr({ as?: string; key: value; by?: value })`
|
|
1977
|
+
- `s.redis.del({ key: value })`
|
|
1978
|
+
- `s.redis.get({ as?: string; key: value })` → as: unknown (FALSE (not null) on a miss)
|
|
1979
|
+
- `s.redis.has({ as?: string; key: value })`
|
|
1980
|
+
- `s.redis.incr({ as?: string; key: value; by?: value })`
|
|
1981
|
+
- `s.redis.keys({ as?: string; search: value })`
|
|
1982
|
+
- `s.redis.pop({ as?: string; key: value; count?: value })`
|
|
1983
|
+
- `s.redis.push({ as?: string; key: value; value: value })`
|
|
1984
|
+
- `s.redis.range({ as?: string; key: value; start?: value; stop?: value })`
|
|
1985
|
+
- `s.redis.ratelimit({ as?: string; key: value; max?: value; ttl?: value; error?: value })`
|
|
1986
|
+
- `s.redis.remove({ as?: string; key: value; value: value; count?: value })`
|
|
1987
|
+
- `s.redis.set({ as?: string; key: value; data: value; ttl?: value; create_only?: value })` → as: boolean (false when create_only meets an existing key (kept))
|
|
1988
|
+
- `s.redis.shift({ as?: string; key: value; count?: value })`
|
|
1989
|
+
- `s.redis.unshift({ as?: string; key: value; value: value })`
|
|
1990
|
+
|
|
1991
|
+
### security
|
|
1992
|
+
|
|
1993
|
+
- `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)
|
|
1994
|
+
- `s.security.create_auth_token` [special]
|
|
1995
|
+
- `s.security.create_curve_key({ as?: string; curve?: "P-256" | "P-384" | "P-521"; format?: "object" | "base64" })`
|
|
1996
|
+
- `s.security.create_password({ as?: string; character_count?: value; require_lowercase?: value; require_uppercase?: value; require_digit?: value; require_symbol?: value; symbol_whitelist?: value })`
|
|
1997
|
+
- `s.security.create_rsa_key({ as?: string; bits?: value; format?: "object" | "base64" })`
|
|
1998
|
+
- `s.security.create_secret_key({ as?: string; bits?: value; format?: "object" | "base64" })`
|
|
1999
|
+
- `s.security.create_uuid({ as?: string })`
|
|
2000
|
+
- `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 })`
|
|
2001
|
+
- `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 })`
|
|
2002
|
+
- `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 })`
|
|
2003
|
+
- `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 })`
|
|
2004
|
+
- `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 })`
|
|
2005
|
+
- `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 })`
|
|
2006
|
+
- `s.security.random_bytes({ as?: string; length?: value })`
|
|
2007
|
+
- `s.security.random_number({ as?: string; min?: value; max?: value })`
|
|
2008
|
+
|
|
2009
|
+
### storage
|
|
2010
|
+
|
|
2011
|
+
- `s.storage.create_attachment({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value; include_meta?: boolean; type?: value })`
|
|
2012
|
+
- `s.storage.create_audio({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value })` [output]
|
|
2013
|
+
- `s.storage.create_file_resource({ as?: string; filename: value; filedata: value })` [output]
|
|
2014
|
+
- `s.storage.create_image({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value })` [output]
|
|
2015
|
+
- `s.storage.create_video({ as?: string; value: value; access?: "public" | "private" = "public"; filename?: value })` [output]
|
|
2016
|
+
- `s.storage.delete_file({ pathname: value })` [output]
|
|
2017
|
+
- `s.storage.read_file_resource({ as?: string; value: value })` → as: { name; size; mime; data: <contents> }
|
|
2018
|
+
- `s.storage.sign_private_url({ as?: string; pathname: value; ttl?: value })`
|
|
2019
|
+
|
|
2020
|
+
### stream
|
|
2021
|
+
|
|
2022
|
+
- `s.stream.from_csv({ as?: string; value: value; separator?: value; enclosure?: value; escape_char?: value })` [output]
|
|
2023
|
+
- `s.stream.from_jsonl({ as?: string; value: value })`
|
|
2024
|
+
- `s.stream.from_request` [special]
|
|
2025
|
+
|
|
2026
|
+
### task
|
|
2027
|
+
|
|
2028
|
+
- `s.task.call` [special]
|
|
2029
|
+
|
|
2030
|
+
### text
|
|
2031
|
+
|
|
2032
|
+
- `s.text.append({ name?: string; value: value })`
|
|
2033
|
+
- `s.text.contains({ name?: string; as?: string; value: value })`
|
|
2034
|
+
- `s.text.ends_with({ name?: string; as?: string; value: value })`
|
|
2035
|
+
- `s.text.icontains({ name?: string; as?: string; value: value })`
|
|
2036
|
+
- `s.text.iends_with({ name?: string; as?: string; value: value })`
|
|
2037
|
+
- `s.text.istarts_with({ name?: string; as?: string; value: value })`
|
|
2038
|
+
- `s.text.ltrim({ name?: string; value: value })`
|
|
2039
|
+
- `s.text.prepend({ name?: string; value: value })`
|
|
2040
|
+
- `s.text.rtrim({ name?: string; value: value })`
|
|
2041
|
+
- `s.text.starts_with({ name?: string; as?: string; value: value })`
|
|
2042
|
+
- `s.text.trim({ name?: string; value: value })`
|
|
2043
|
+
|
|
2044
|
+
### tool
|
|
2045
|
+
|
|
2046
|
+
- `s.tool.call` [special]
|
|
2047
|
+
|
|
2048
|
+
### trigger
|
|
2049
|
+
|
|
2050
|
+
- `s.trigger.call` [special]
|
|
2051
|
+
|
|
2052
|
+
### util
|
|
2053
|
+
|
|
2054
|
+
`s.util.sleep`'s `value` is in SECONDS; a decimal is allowed (`c.decimal(0.5)` = 500 ms).
|
|
2055
|
+
|
|
2056
|
+
- `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)
|
|
2057
|
+
- `s.util.get_all_input({ as?: string })` → as: unknown (inside an ATTACHED middleware it is a { type, vars } envelope whose vars DIFFERS BY PHASE: pre → the request inputs; post → { status, result } (plus payload on an error), NOT the request — read the request in post with s.util.get_raw_input. See llms/kinds-core.md)
|
|
2058
|
+
- `s.util.get_env({ as?: string })`
|
|
2059
|
+
- `s.util.get_input` [special]
|
|
2060
|
+
- `s.util.get_raw_input` [special]
|
|
2061
|
+
- `s.util.get_vars({ as?: string })`
|
|
2062
|
+
- `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} } — ref("geo.location.latitude"), ref("geo.city.name"); radius is KILOMETRES. ⚠ Every leaf is nullable (region/city/postal often null on a normal hit). `city` is an OBJECT: a bare ref("geo.city") into a text column fails and { safe: true } does NOT help; drill to city.name. The whole var is null for an unresolvable address)
|
|
2063
|
+
- `s.util.post_process` [special]
|
|
2064
|
+
- `s.util.send_email({ as?: string; api_key?: value; service_provider?: "resend" | "xano"; subject?: value; message?: value; to?: value; bcc?: value; cc?: value; from?: value; reply_to?: value; scheduled_at?: value })`
|
|
2065
|
+
- `s.util.set_header({ value: value; duplicates?: "replace" | "append" })`
|
|
2066
|
+
- `s.util.sleep({ value: value })`
|
|
2067
|
+
- `s.util.template_engine({ as?: string; value: value })`
|
|
2068
|
+
|
|
2069
|
+
### webflow
|
|
2070
|
+
|
|
2071
|
+
- `s.webflow.request` [special]
|
|
2072
|
+
|
|
2073
|
+
### workflow_test
|
|
2074
|
+
|
|
2075
|
+
Run another workflow test from inside one. Pass the `workflowTest()` def handle, not a name.
|
|
2076
|
+
|
|
2077
|
+
- `s.workflow_test.call` [special]
|
|
2078
|
+
|
|
2079
|
+
### zip
|
|
2080
|
+
|
|
2081
|
+
- `s.zip.add_to_archive({ file: value; filename: value; zip: value; password?: value; password_encryption?: value })`
|
|
2082
|
+
- `s.zip.create_archive({ as?: string; filename: value; password?: value; password_encryption?: "standard" | "AES-128" | "AES-192" | "AES-256" })`
|
|
2083
|
+
- `s.zip.delete_from_archive({ filename: value; zip: value; password?: value })`
|
|
2084
|
+
- `s.zip.extract({ as?: string; zip: value; password?: value })`
|
|
2085
|
+
- `s.zip.view_contents({ as?: string; zip: value; password?: value })`
|