@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
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
# Authoring reference
|
|
2
|
+
|
|
3
|
+
Tables and fields, the statement and value surfaces, inputs, and the request-scoped machinery around them.
|
|
4
|
+
|
|
5
|
+
## Tables & fields
|
|
6
|
+
|
|
7
|
+
`f.*` covers the full column catalog — scalars, `f.timestamp`, the four file resources, the
|
|
8
|
+
six `f.geo.*` types, `f.enum(values)`, `f.vector(size)`, `f.object(children)`. A `json` column
|
|
9
|
+
can declare the shape stored inside it with `f.json({ children: [{ name, type }] })` — an
|
|
10
|
+
ordered array, unlike the named map `f.object` takes, because the engine persists json
|
|
11
|
+
children in the order given. Omit it for an unstructured json column. Foreign keys
|
|
12
|
+
are `f.tableRef(table)`, whose link resolves to the target table's guid at export — a
|
|
13
|
+
target that isn't registered on the same workspace fails **there**, naming the table and the
|
|
14
|
+
schema field, rather than during the import it would otherwise break. Any
|
|
15
|
+
scalar becomes a **list column** with `{ array: true }`, surfacing as `string[]` in
|
|
16
|
+
`InferRow`. Tables take a named-map schema, filter methods carry args (`"min:8"`), and
|
|
17
|
+
`views[]` encode through the shared comparison encoder.
|
|
18
|
+
|
|
19
|
+
- **A column `default` must stay within the BMP.** A 4-byte character (codepoint > U+FFFF,
|
|
20
|
+
e.g. an emoji) is mangled into invalid UTF-8 by the engine's default pipeline, so it is
|
|
21
|
+
rejected at export rather than 500ing at deploy with Postgres `22021`. Accents, `€`, and
|
|
22
|
+
most CJK are fine; otherwise put the value on an endpoint input, applied at runtime bind.
|
|
23
|
+
- **`id` and `created_at` auto-inject** at the head of the schema unless `system: false` or
|
|
24
|
+
you declare them (`idType: "uuid"` for a uuid key). A declared `id` is the primary key:
|
|
25
|
+
`f.int()` or `f.uuid()`, made required whatever its options say; any other type is refused. A
|
|
26
|
+
`system: false` table still declares `id` and a `primary` index on it. Both are usable wherever a column name
|
|
27
|
+
is expected and both appear in `InferRow`. The standard indexes — `primary(id)`,
|
|
28
|
+
`btree(created_at desc)`, plus `gin(xdo)` when the table stores fields as JSON —
|
|
29
|
+
auto-prepend, de-duped against your own. Declare yours as
|
|
30
|
+
`{ type, fields: [{ name, op? }] }`; `"unique"` is shorthand for `"btree|unique"`.
|
|
31
|
+
- **`use_xdo` picks the storage mode** — every field as JSON under the internal `xdo` column,
|
|
32
|
+
or a real Postgres column per field. It is a workspace setting (default `false`) each table
|
|
33
|
+
mirrors, overridable per table with `table({ useXdo })`, resolved at `export()` so the two
|
|
34
|
+
can register in any order.
|
|
35
|
+
|
|
36
|
+
## Statements, values & inputs
|
|
37
|
+
|
|
38
|
+
The `stack` of a function/query/tool is a list of statements, all reachable through one
|
|
39
|
+
discoverable, typed namespace — `s`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
stack: [
|
|
43
|
+
s.set_var("total", c.int(0)),
|
|
44
|
+
s.math.add({ name: "total", value: c.int(5) }),
|
|
45
|
+
s.array.find({ as: "hit", expr: ref("items"), if: expr(ref("$this"), "=", c.int(1)) }),
|
|
46
|
+
s.conditional({ when: expr(ref("total"), ">", c.int(0)), then: [s.return(ref("total"))] }),
|
|
47
|
+
s.function.run({ fn: getUser, as: "u", input: { id: ref("total") } }),
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Tab-complete `s.` to explore. Each declarative statement takes one typed args object;
|
|
52
|
+
control-flow specials (`s.set_var`, `s.conditional`, `s.for`, `s.foreach`, `s.while`,
|
|
53
|
+
`s.group`, `s.switch`, `s.try_catch`, `s.return`, …) keep their authored signatures. **Every
|
|
54
|
+
statement also carries `description` and `disabled`** — inline on the object-arg factories,
|
|
55
|
+
a trailing options object on the positional specials. `disabled: true` is Xano's
|
|
56
|
+
commented-out state: the step stays in the stack and the engine skips it.
|
|
57
|
+
|
|
58
|
+
**Filter a statement's result as it binds.** Any statement with an `as` also takes
|
|
59
|
+
`asFilters` — the editor's `return as <var> | upper` — applied in order, from the same
|
|
60
|
+
`fl.*` catalog as value filters:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
s.security.create_uuid({ as: "token", asFilters: [fl.upper()] })
|
|
64
|
+
s.set_var("email", inp("raw"), { asFilters: [fl.trim(), fl.lower()] })
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It saves a follow-up `s.set_var` for the common "bind it in a different shape" case. A
|
|
68
|
+
statement that binds nothing does not offer the option.
|
|
69
|
+
|
|
70
|
+
**The chain retypes the value.** `InferResponse` folds each filter's declared result, so a
|
|
71
|
+
filtered binding reports what it actually holds rather than `unknown`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
s.db.query({ table: users, as: "rows", asFilters: [fl.count()] }) // rows: number
|
|
75
|
+
s.db.query({ table: users, as: "rows", asFilters: [fl.reverse(), fl.first()] }) // rows: Row
|
|
76
|
+
withFilters(ref("rows"), fl.count()) // number
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Filters the engine declares as returning `any` — `get`, `set`, `transform`, `json_decode` —
|
|
80
|
+
fold to `unknown`, since no declaration could name their shape. Note this models a filter's
|
|
81
|
+
OUTPUT, not its input: a filter applied to a value it cannot accept returns `null` at
|
|
82
|
+
runtime rather than erroring, and still types as its declared result.
|
|
83
|
+
|
|
84
|
+
**Fields with a fixed set of values take a bare literal.** Where the engine accepts only
|
|
85
|
+
certain spellings, the field's type is that set, so autocomplete offers them and a typo is a
|
|
86
|
+
compile error rather than a runtime failure after deploy:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
s.ai.external.mcp.tool.run({ url, tool, connection_type: "stream" }) // ✅ "sse" | "stream"
|
|
90
|
+
s.ai.external.mcp.tool.run({ url, tool, connection_type: "streaming" }) // ❌ compile error, and throws
|
|
91
|
+
s.ai.external.mcp.tool.run({ url, tool, connection_type: inp("mode") }) // ✅ resolved at runtime
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`"stream"` and `c.text("stream")` encode identically — use whichever reads better. A value
|
|
95
|
+
the SDK can't evaluate (an `inp`/`ref`, or anything with a filter chain) is never checked,
|
|
96
|
+
so a computed field stays authorable.
|
|
97
|
+
|
|
98
|
+
One field is **resolved at encode time** and so is narrower than the rest: `s.db.query`'s
|
|
99
|
+
`returnType` picks which `context.return` block gets written, which has to happen before
|
|
100
|
+
there is a runtime. It takes the bare literal only — a tagged value is a compile error, and a
|
|
101
|
+
dynamic one (`inp`/`ref`, or any filter chain) throws rather than deferring. A spelling
|
|
102
|
+
outside the set throws too, in place of quietly falling back to `list`.
|
|
103
|
+
|
|
104
|
+
**The db family.** Single-record reads and mutations match one field
|
|
105
|
+
(`{ fieldName, fieldValue }`, defaulting to `id`) — there is no composite `(a, b)` form; for
|
|
106
|
+
a two-column lookup use `s.db.query` with a `where` array. Writes take a partial `row: {…}`,
|
|
107
|
+
and an `s.db.edit` writes **only** the columns you list, leaving every unmentioned column at
|
|
108
|
+
its stored value. A cell takes a tagged value or a **bare JS literal typed against its
|
|
109
|
+
column** — `row: { is_hidden: true, notes: "…" }` is exactly `{ is_hidden: c.bool(true),
|
|
110
|
+
notes: c.text("…") }`. The tag comes from the column, so the literal is checked against it:
|
|
111
|
+
a string on an `f.bool()` column is a compile error, and an `f.enum()` column keeps its
|
|
112
|
+
member union. Only `s.db.query` takes a `where`, and its `where`/`sort`/`paging`/`output`
|
|
113
|
+
are applied **by the engine**, not in your stack.
|
|
114
|
+
|
|
115
|
+
**Bulk writes: `bulk.update` REPLACES the row, `bulk.patch` doesn't.** `s.db.bulk.update`
|
|
116
|
+
writes every column an item omits to its zero value (`""`/`0`/`null`), with an HTTP 200 and
|
|
117
|
+
no error — `{ id: 7, status: "done" }` blanks the rest of row 7. `s.db.bulk.patch` writes
|
|
118
|
+
only the keys each item carries, which is what "update these rows" almost always means.
|
|
119
|
+
`export()` warns when a static `items` array omits columns of the bound table (and
|
|
120
|
+
`--strict` fails on it), but an `items` built from a `ref`/`inp` can't be inspected. Related:
|
|
121
|
+
`s.db.bulk.delete` with no `where` is a truncate, so it refuses to encode unless you say
|
|
122
|
+
`allRows: true`.
|
|
123
|
+
|
|
124
|
+
What each op binds decides your response type: `s.db.get` binds **`null`** on a miss (it does
|
|
125
|
+
not throw — null-check it), `s.db.add`/`edit`/`patch` bind the **full written row** including
|
|
126
|
+
auto-assigned `id`/`created_at`, `s.db.del` binds `null`, and `edit`/`del` **throw**
|
|
127
|
+
`NotFound` (404) when nothing matches. `InferResponse` derives all of that automatically.
|
|
128
|
+
|
|
129
|
+
`s.db.query` mirrors the whole Xano query builder — `returnType`, `bind` joins, computed
|
|
130
|
+
`eval` columns, `aggregate` groups, `distinct`, and the full operator set via
|
|
131
|
+
`cmp(left, op, right)` with `and(...)`/`or(...)` for boolean groups. Signatures are in
|
|
132
|
+
`llms/statements-data.md`; five behaviors are worth knowing here:
|
|
133
|
+
|
|
134
|
+
- **A join condition spells its two sides differently.** The joined table's column takes its
|
|
135
|
+
`as` alias; this query's own columns stay bare:
|
|
136
|
+
`bind: [{ table: users, as: "author", join: "left", where: expr(col("author_id"), "=", col("author.id")) }]`.
|
|
137
|
+
Qualifying your own column by the table's name (`col("posts.author_id")`) resolves only if
|
|
138
|
+
the query also sets `tableAlias` — the alias the qualifier is matched against. Unqualified,
|
|
139
|
+
the engine reads the operand as a text literal and fails at runtime with a parse error
|
|
140
|
+
naming the *other* operand, so `db.query` rejects that spelling at export instead.
|
|
141
|
+
|
|
142
|
+
- **A join adds no columns to the returned row.** With or without a `bind`, a row is the
|
|
143
|
+
queried table's columns — which is what `InferResponse` types, so there is no `row.author`
|
|
144
|
+
to read. To bring a joined column back, project it with an `eval` whose `name` is the dotted
|
|
145
|
+
path: `eval: [{ name: "author.name", as: "author_name" }]` puts `author_name` on the row and
|
|
146
|
+
on the inferred type. A *bare* name there fails at runtime (it qualifies to the base table),
|
|
147
|
+
and a dotted joined column in `output` is dropped with no error.
|
|
148
|
+
|
|
149
|
+
- **Paging changes the response shape.** Supplying `paging` with metadata on (the default)
|
|
150
|
+
returns a **paging envelope** — `{ items, curPage, nextPage, prevPage, offset, perPage,
|
|
151
|
+
itemsReceived }`, plus totals when `totals: true` — instead of a bare `Row[]`, and
|
|
152
|
+
`InferResponse` reflects that. Pass `metadata: false` to keep the bare array. Read
|
|
153
|
+
`nextPage` (`number | null`) as the typed has-next signal.
|
|
154
|
+
|
|
155
|
+
It also moves what `output` selects from. The selection applies to the value the statement
|
|
156
|
+
binds, so under the envelope its roots are the counters and `items.<column>` —
|
|
157
|
+
`output: ["itemsReceived", "curPage", "items.id", "items.title"]`. A bare column list there
|
|
158
|
+
matches no envelope key, so every key is dropped and the endpoint answers `[]` at HTTP 200
|
|
159
|
+
with no error. `export()` warns and `--strict` fails; with `paging: { metadata: false }` the statement
|
|
160
|
+
binds the rows and bare columns are the right form.
|
|
161
|
+
- **Don't author `mixed(...)` conditions.** Xano's editor allows a container whose terms
|
|
162
|
+
don't all join the same way, so pulled workspaces contain it and it round-trips — but the
|
|
163
|
+
stored form doesn't record the intended grouping, and the two places it can appear
|
|
164
|
+
disagree: a branch folds terms strictly left to right (`a OR b AND c` = `(a OR b) AND c`)
|
|
165
|
+
while a `db.query` filter inherits SQL's AND-before-OR precedence (`a OR (b AND c)`). Write
|
|
166
|
+
`and(or(a, b), c)` or `or(a, and(b, c))` — each says exactly one thing in every context.
|
|
167
|
+
- **`like`/`ilike` take the operand as the pattern, verbatim.** The operand *is* the pattern,
|
|
168
|
+
so `cmp(col("body"), "ilike", inp("q"))` with a bare term matches only a whole-string equal —
|
|
169
|
+
and the endpoint answers 200 with an empty list, which reads as "no results for that query".
|
|
170
|
+
For substring search use `includes` / `not includes`, which wrap the operand in `%…%`
|
|
171
|
+
themselves and match case-insensitively. Prefer that over building `"%" + term + "%"`: a
|
|
172
|
+
hand-built pattern is non-empty even for a blank term, which defeats the `ignoreEmpty` below.
|
|
173
|
+
`contains` / `@>` / `overlaps` are JSON and array containment, not text — on a text column
|
|
174
|
+
they fail with a parse error.
|
|
175
|
+
- **`ignoreEmpty` DROPS the clause — it does not match zero rows.** `cmp(col("owner"), "in",
|
|
176
|
+
ids, { ignoreEmpty: true })` with an empty `ids` returns the **unfiltered** table, where the
|
|
177
|
+
same clause without the flag matches nothing. Never put it on a filter that scopes rows to a
|
|
178
|
+
permitted set: an empty permission list then returns everything. It is for an optional search
|
|
179
|
+
filter, where a blank box really does mean "don't filter". `export()` warns when the operand
|
|
180
|
+
is empty in the bundle; a runtime-empty list is yours to reason about.
|
|
181
|
+
- **Compose a rule set as siblings, not a folded chain.** `and(...)`/`or(...)` take any number
|
|
182
|
+
of terms and encode flat, so build the array and spread it — `and(...rules)`. Folding one term
|
|
183
|
+
at a time (`rules.reduce((acc, r) => and(acc, r))`) nests a container per rule, and nesting
|
|
184
|
+
costs quadratic bytes: 512 terms are 394 KiB as siblings and 21 MiB folded. Past 128 levels the
|
|
185
|
+
build fails with a message naming the fix. Mixing joins? Group each run: `and(or(...anyOf), ...allOf)`.
|
|
186
|
+
- **An aggregate or `eval` `name` is written bare** (`"status"`) and alias-qualified on emit;
|
|
187
|
+
the engine rejects a bare column in either, and an already-dotted joined column passes
|
|
188
|
+
through. The statement also declares the alias it qualified with, so the qualified name
|
|
189
|
+
resolves — nothing to set by hand.
|
|
190
|
+
- **`eval` is where vector search lives.** An `eval` filter pipeline compiles to SQL, so it
|
|
191
|
+
resolves a different filter registry than `fl.*` (which runs in the request) — including
|
|
192
|
+
the distance filters an `f.vector` column needs. Compute the distance, then sort by the
|
|
193
|
+
alias it grafts onto the row; the ranking happens in the database, over the column's index:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
s.db.query({
|
|
197
|
+
table: chunk, // index: [{ type: "vector", fields: [{ name: "embedding", op: "vector_cosine_ops" }] }]
|
|
198
|
+
eval: [{ name: "embedding", as: "distance",
|
|
199
|
+
filters: [{ name: "vector_cos_distance", arg: [inp("q")] }] }],
|
|
200
|
+
sort: [{ sortBy: "distance", dir: "asc" }], // nearest first
|
|
201
|
+
paging: { per_page: 10, metadata: false },
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Match the filter to the index `op` (`VECTOR_FILTERS`, on `@xano/sdk/internal`, lists the
|
|
206
|
+
family). The same filter
|
|
207
|
+
works on a `where` operand to cut off *by distance* rather than by row count.
|
|
208
|
+
- **A `where` operand may carry filters — except the request-time timestamp ones.** A `where`
|
|
209
|
+
is compiled into SQL, and most filters have a SQL form there (`trim`, `concat`, `upper`,
|
|
210
|
+
`lower`, and the `epochms_add_day` / `epochms_sub_month` family). The request-time timestamp
|
|
211
|
+
filters do not — `epochms_transform`, `epochms_add_ms`, `epochms_add_secs`, `epochms_date`,
|
|
212
|
+
`epochms_from_format` — and the engine doesn't degrade gracefully: the request dies with a
|
|
213
|
+
bare fatal naming nothing. For a relative cutoff, use the SQL-side family through `qf.*`
|
|
214
|
+
(`fl.*` has no builder for it): `withFilters(c.now(), qf.epochms_sub_hour(1))`,
|
|
215
|
+
`qf.epochms_add_day(7)`, … — or compute it in an earlier `s.set_var` and `ref()` that.
|
|
216
|
+
`export()` warns; a bare `c.now()` is always fine.
|
|
217
|
+
- **A `s.switch` case without `break: true` falls through.** The engine's default is
|
|
218
|
+
fallthrough, so a matched case also runs every *later* case body — and the `default` block
|
|
219
|
+
too. Whatever those bodies write gets written two or three times, at HTTP 200, with `tsc`
|
|
220
|
+
and a plain `export` both clean. Set `break: true` on every case unless you mean the
|
|
221
|
+
cascade; `export()` warns when a break-less case has somewhere to fall into.
|
|
222
|
+
|
|
223
|
+
**Addons** enrich each returned row with related data, attached to the row-returning ops
|
|
224
|
+
(`query`/`get`/`add`/`edit`/`patch`). An addon is a single table-bound db query rather than a
|
|
225
|
+
statement stack: `addon({ table, where, output, cardinality })`, where `where` binds it to
|
|
226
|
+
the parent row and `cardinality` shapes the graft (`"single"` object, the default `"list"`,
|
|
227
|
+
`"count"`, `"exists"`, `"aggregate"`).
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
export const authorAddon = addon({
|
|
231
|
+
name: "author",
|
|
232
|
+
table: userTable,
|
|
233
|
+
where: expr(col("id"), "=", inp("user_id")), // bind to the parent row
|
|
234
|
+
output: ["id", "name"],
|
|
235
|
+
cardinality: "single",
|
|
236
|
+
input: { user_id: input.int({ required: true }) },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
s.db.query({
|
|
240
|
+
table: post,
|
|
241
|
+
addon: [{ addon: authorAddon, as: "_author", input: { user_id: out("author") } }],
|
|
242
|
+
as: "rows",
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Attaching a typed handle merges the graft onto the row shape in `InferResponse` with no cast;
|
|
247
|
+
a **bare-name** reference grafts `unknown`. Author `as` relative to a row (`_author`) — when
|
|
248
|
+
the query returns a paging envelope the `items[]` offset is added for you. If an alias
|
|
249
|
+
**shadows an existing column** the build throws, because the engine would silently overwrite
|
|
250
|
+
that column at runtime (Xano convention: prefix with `_`).
|
|
251
|
+
|
|
252
|
+
**Values** — `c.int/text/bool/decimal/null/obj/array`, `c.now()`, `ref(var)`, `inp(input)`,
|
|
253
|
+
`col(name)`, the context refs `auth(path?)`/`env(name)`/`setting(name)`/`sys.*()`,
|
|
254
|
+
`out(name)` for a parent-row column in an addon input, and `toolset(path)` for the token
|
|
255
|
+
and URL parameters bound while a tool runs under its toolset. `withFilters(value, fl.a(), fl.b())`
|
|
256
|
+
attaches the value pipeline from a typed catalog of filters generated from the engine's own
|
|
257
|
+
sources.
|
|
258
|
+
|
|
259
|
+
- **`c.obj`/`c.array` take plain JSON literals only.** A nested tagged value
|
|
260
|
+
(`inp`/`ref`/`auth`/`c.*`) is a compile error. For a computed object — a response, or an
|
|
261
|
+
`api.request` `params` — use a record of values (`{ count: ref("count") }`). For a dynamic
|
|
262
|
+
object argument use `obj({...})`, which builds a checked expression.
|
|
263
|
+
- **An `obj({...})` member may carry a filter chain.** That matters most for the null-safe
|
|
264
|
+
drill: `db.get` binds `null` on a miss, so `obj({ city: ref("row.address.city", { safe: true }) })`
|
|
265
|
+
is the normal shape — no per-member `s.set_var` to hoist it out. `c.now()`, `env()` and
|
|
266
|
+
`sys.*()` are members too. `{ safe: true }` is for a base that exists and may be null: on a
|
|
267
|
+
base nothing binds it would hide a typo behind a `null`, which is why `export()` warns
|
|
268
|
+
(with a did-you-mean) when a `ref()`'s base segment names no `as` in that stack.
|
|
269
|
+
- **`c.int` takes a string or bigint past `Number.MAX_SAFE_INTEGER`.** The engine stores
|
|
270
|
+
integers as strings and has no 53-bit limit, so `c.int("18446744073709551615")` is exact
|
|
271
|
+
where the number literal for it is already `…616`. A `number` that is not a safe integer
|
|
272
|
+
throws rather than encoding the rounded value.
|
|
273
|
+
- **A bare scalar works in any `fl.*` argument.** `fl.get("a.b", 0)` encodes identically to
|
|
274
|
+
`fl.get(c.text("a.b"), c.int(0))`; strings, numbers and booleans are all wrapped for you.
|
|
275
|
+
Objects and arrays still need `c.obj`/`c.array`.
|
|
276
|
+
- **A typed `fl.*` call is capped at its declared argument count.** Passing more throws,
|
|
277
|
+
in the type and at runtime — an extra argument used to ride into the filter's arg list for
|
|
278
|
+
the engine to ignore or fail on. `filter("name", …)` is the untyped escape. Seven filters the
|
|
279
|
+
catalog under-declares stay variadic (`concat`, `index_by`, `get`, `array_merge`,
|
|
280
|
+
`array_merge_recursive`, `jwe_encode`, `jwe_decode`). The other 102 filters take **no**
|
|
281
|
+
arguments at all — they are emitted `()`, so `fl.abs(x)` is a compile error rather than an
|
|
282
|
+
argument silently shipped to an engine that does not accept one.
|
|
283
|
+
- **`api.request` headers take a `{ "Name": value }` record**, values may be tagged:
|
|
284
|
+
`headers: { "x-api-key": env("KEY") }`. Prefer a header over a `?key=` query param for a
|
|
285
|
+
credential — a URL travels into access logs, proxies and `Referer`. That is not envelope
|
|
286
|
+
safety, though: the `as` envelope's `request` half mirrors `url`, `params` **and**
|
|
287
|
+
`headers`, so never return it raw from a credentialed request; read `response.result`.
|
|
288
|
+
- **Some filters require an argument their own docs call optional.** Filter arguments are
|
|
289
|
+
positional, and a short call is refused by the engine before the filter runs — so
|
|
290
|
+
`fl.csv_encode()` and `fl.number_format()` are compile errors here rather than a failure on
|
|
291
|
+
a deployed endpoint. Which filters those are is probed, not declared: `fl.round()` is also
|
|
292
|
+
documented optional and genuinely works. Pass every argument the signature shows without a
|
|
293
|
+
`?`; the runtime guard names the count if you reach it from JavaScript.
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
withFilters(ref("rows"), fl.csv_encode(",", '"', "\\")), // not fl.csv_encode()
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
- **`fl.csv_encode` writes no header — `fl.csv_create` is the one that does.** They read as
|
|
300
|
+
interchangeable and are not. `csv_encode` emits each row's values in *that row's* key order
|
|
301
|
+
with no normalization across rows, so rows whose keys differ in order or count misalign
|
|
302
|
+
columns silently; nested cells are JSON-encoded, `false` writes empty, and a piped array of
|
|
303
|
+
scalars collapses to a single line. `csv_create` takes the column names as its **piped**
|
|
304
|
+
value and the data as its `rows` argument.
|
|
305
|
+
- **Only `fl.fsort({ type: "number" })` sorts numerically.** Every other comparator —
|
|
306
|
+
including a spelling the engine does not recognize — sorts as case-insensitive text,
|
|
307
|
+
silently and with no error, so `[2, 10, 1]` comes back `[1, 10, 2]`. A lexicographic sort
|
|
308
|
+
agrees with a numeric one whenever the values share a digit count, so this looks correct
|
|
309
|
+
on small data and goes wrong on real data: a "top N by score/distance/recency" endpoint
|
|
310
|
+
returns the right rows in the wrong order. The union rejects the two plausible wrong
|
|
311
|
+
spellings (`"decimal"`, `"int"`) outright.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
withFilters(ref("rows"), fl.fsort({ path: "score", type: "number", asc: true })),
|
|
315
|
+
```
|
|
316
|
+
- **`col()` does not resolve to a stored value inside a `db.edit` `row`.** To
|
|
317
|
+
read-modify-write a column — incrementing a counter — `db.get` the row first and pipe its
|
|
318
|
+
bound value through a filter. `col()` evaluates to `null` there, so `fl.add(1)` computes
|
|
319
|
+
`null + 1` and the engine aborts.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
s.db.get({ table, fieldValue: inp("id"), as: "current" }),
|
|
323
|
+
s.db.edit({ table, fieldValue: inp("id"), row: { clicks: withFilters(ref("current.clicks"), fl.add(c.int(1))) } }),
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
That pair is **not atomic** — concurrent writers can lose an increment, and no atomic
|
|
327
|
+
increment statement exists. A genuinely safe counter needs the arithmetic in the database
|
|
328
|
+
via `s.db.direct_query`, which in turn needs the table's *physical* Postgres name; that
|
|
329
|
+
name is assigned at import and is not knowable from a `table()` def, so it has to be
|
|
330
|
+
hardcoded after inspecting the deployed table. A typed path requires an engine change
|
|
331
|
+
([issue #35](https://github.com/xanots/sdk/issues/35)).
|
|
332
|
+
- **A JavaScript body is written as a function, not a `c.text` string.** The lambda
|
|
333
|
+
statement (`s.lambda`) and eight filters (`fl.map`/`filter`/`some`/`every`/`find`/
|
|
334
|
+
`findIndex`/`reduce`/`lambda`) run JavaScript against a small, closed set of injected
|
|
335
|
+
identifiers — and which ones are in scope depends on the surface. Write the body inline
|
|
336
|
+
and the **surface is implied by where it sits**: the bindings are the function's
|
|
337
|
+
parameters, typed from the position, so your editor supplies them and a wrong name is a
|
|
338
|
+
compile error rather than a wrong value in production.
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
// reduce's accumulator is `$result`. Autocomplete says so; `$acc` does not compile.
|
|
342
|
+
withFilters(ref("prices"), fl.reduce({ initial_value: 0, code: ({ $result, $this }) => $result + $this })),
|
|
343
|
+
|
|
344
|
+
// A map body, typed as a map body — nothing names the surface.
|
|
345
|
+
withFilters(ref("prices"), fl.map(({ $this, $index }) => $this * ($index + 1))),
|
|
346
|
+
|
|
347
|
+
// The statement surface binds ambient state only: `$this` here is a compile error.
|
|
348
|
+
s.lambda({ as: "total", code: ({ $var }) => $var.subtotal * 1.2 }),
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The parameters are a fiction — only the **body** is sent, and the engine injects the
|
|
352
|
+
bindings as free identifiers — so destructure them. `(b) => b.$this * 2` would emit
|
|
353
|
+
`return b.$this * 2` with `b` undefined at runtime, and the SDK refuses it. Keep each
|
|
354
|
+
binding's own name: `({ $this: item })`, a nested pattern, a default or a rest element is
|
|
355
|
+
refused the same way, since only the body — never the pattern — reaches the engine.
|
|
356
|
+
|
|
357
|
+
**`fl.transform` is not one of these.** It sits next to them and reads like one, but it
|
|
358
|
+
takes a Xano *expression* — no `return`, and the piped value binds as `$0` (or `$$`),
|
|
359
|
+
not `$this`. A `$this` there resolves to null and the call still returns HTTP 200, so
|
|
360
|
+
the SDK refuses both spellings at author time and points at `$0`.
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
// An expression over the piped value — not a JS body.
|
|
364
|
+
withFilters(ref("prices"), fl.transform("$0 * 1.2")),
|
|
365
|
+
|
|
366
|
+
// Parenthesize a pipe inside an object literal, or the filter argument's comma is read
|
|
367
|
+
// as the key separator and every later key silently vanishes.
|
|
368
|
+
withFilters(ref("items"), fl.transform('{ names: ($0|sort|join:","), n: ($0|count) }')),
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
For a body built away from its call site, `lam.*` names the surface explicitly:
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
const rate = 0.2;
|
|
375
|
+
// Nothing from the enclosing scope crosses implicitly — declare what the body needs.
|
|
376
|
+
// The capture key must differ from the module binding: the loader renames one of two
|
|
377
|
+
// same-named bindings and the prelude is written under the original name.
|
|
378
|
+
s.lambda({ as: "vat", code: lam.fn(({ $var }, { capturedRate }) => $var.total * capturedRate, { surface: "s.lambda", capture: { capturedRate: rate } }) }),
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Omit `surface` and the check is deferred to wherever the body lands, which is the thing
|
|
382
|
+
that knows. `lam.file("./lambdas/total.ts")` (relative to the calling module, like an
|
|
383
|
+
import) reads a default-exported function of the
|
|
384
|
+
same shape from its own type-checked module — the deterministic option under a bundler,
|
|
385
|
+
where a function's own source is whatever the bundler emitted. It needs a filesystem, so
|
|
386
|
+
it ships on the Node entry only: `import { lam } from "@xano/sdk/node"`, whose `lam`
|
|
387
|
+
carries `fn` and `raw` unchanged. `lam.raw(code)` is the text escape hatch, guarded
|
|
388
|
+
identically, and works on either entry. The full
|
|
389
|
+
binding table per surface is in `llms/lambda.md`.
|
|
390
|
+
|
|
391
|
+
Three things to know, all live-verified against a real engine:
|
|
392
|
+
|
|
393
|
+
- A body that **throws does not fail the request** — the engine returns its diagnostic
|
|
394
|
+
text as the value with HTTP 200, so the failure arrives as bad data rather than an
|
|
395
|
+
error. That is engine behavior and not interceptable from an SDK; validate before
|
|
396
|
+
consuming a lambda result numerically, and prefer an authored body, which cannot fail
|
|
397
|
+
that way for a binding reason.
|
|
398
|
+
- The body is a **function body, not a module**: it must `return`, and a top-level
|
|
399
|
+
`import` is a syntax error. Reach a dependency through the **preloaded globals** —
|
|
400
|
+
`crypto`, `fetch`, `Buffer`, `axios`, `jose`, `_`, `math`, `moment`, `DateTime`,
|
|
401
|
+
`uuid` and friends, which need no specifier. A dynamic `import("…")` or `require("…")`
|
|
402
|
+
with a **literal specifier is not portable**: some instances bundle the body before
|
|
403
|
+
running it and resolve every literal specifier ahead of time, so `await
|
|
404
|
+
import("node:crypto")` comes back as the text `Could not resolve "node:crypto"` with
|
|
405
|
+
HTTP 200; others resolve it at run time and it works
|
|
406
|
+
([issue #265](https://github.com/xanots/sdk/issues/265)).
|
|
407
|
+
- `console` output goes to the **request log**, not stdout.
|
|
408
|
+
|
|
409
|
+
A plain `c.text(...)` body is still accepted and gets the same build-time check — the
|
|
410
|
+
guard sits at the call site, not inside `lam.*` — so an unknown `$identifier` fails
|
|
411
|
+
whichever way you write it ([issue #221](https://github.com/xanots/sdk/issues/221)).
|
|
412
|
+
- **`c.expression("…")` is carried through verbatim and NOT validated.** Xano SDK does not
|
|
413
|
+
parse it or type-check it; nothing inside participates in `InferResponse`, so a var named
|
|
414
|
+
there is invisible to a rename that updates every typed `ref()`. A malformed expression
|
|
415
|
+
fails at runtime; one that is merely wrong (`$var.tota1`) returns a wrong answer. Reach for
|
|
416
|
+
it only for syntax the typed surfaces can't express — `~` concatenation, inline arithmetic,
|
|
417
|
+
conditionals — and note it is **not** the `expr()` condition builder.
|
|
418
|
+
(`c.expressionLegacy` exists only so `codegen` can return an older stored form.)
|
|
419
|
+
|
|
420
|
+
**System / request variables (`sys.*`).** Xano's built-in request context reads as
|
|
421
|
+
`$env.$remote_ip` in XanoScript — note the **second `$`**: these are settings with a
|
|
422
|
+
`$`-prefixed name, the same tag `env()` emits. That prefix is the footgun, because
|
|
423
|
+
`env("remote_ip")` reads a workspace env var literally named `remote_ip` (almost always
|
|
424
|
+
unset → null) rather than the caller's IP. `sys.*` spells the prefixed names for you:
|
|
425
|
+
|
|
426
|
+
| accessor | var | | accessor | var |
|
|
427
|
+
|---|---|---|---|---|
|
|
428
|
+
| `sys.remoteIp()` | `$remote_ip` | | `sys.datasource()` | `$datasource` |
|
|
429
|
+
| `sys.requestMethod()` | `$request_method` | | `sys.branch()` | `$branch` |
|
|
430
|
+
| `sys.requestUri()` | `$request_uri` | | `sys.tenant()` | `$tenant` |
|
|
431
|
+
| `sys.requestQueryString()` | `$request_querystring` | | `sys.release()` | `$release` |
|
|
432
|
+
| `sys.httpHeaders()` | `$http_headers` | | `sys.platform()` | `$platform` |
|
|
433
|
+
| `sys.requestAuthToken()` | `$request_auth_token` | | `sys.isDebugger()` | `$debugger` |
|
|
434
|
+
| `sys.apiBaseUrl()` | `$api_baseurl` | | | |
|
|
435
|
+
|
|
436
|
+
`setting("$<name>")` covers anything `sys` doesn't. The one that matters most in practice is
|
|
437
|
+
`sys.remoteIp()`, the rate-limit key for public endpoints.
|
|
438
|
+
|
|
439
|
+
**Inputs** — `input.*` mirrors `f.*` exactly: every engine-legal field type is a valid
|
|
440
|
+
function/query input, with `input.object(children)` and `input.list(element)` for structured
|
|
441
|
+
shapes. Comparisons use `= != > < >= <=`.
|
|
442
|
+
|
|
443
|
+
Binding coerces rather than rejects in two places. `input.timestamp` takes a number as epoch
|
|
444
|
+
**milliseconds** unchanged, so epoch seconds (`date +%s`) land in 1970, and it drops the
|
|
445
|
+
fractional seconds of an ISO-8601 string. `input.list` wraps a non-array into a one-item list,
|
|
446
|
+
and an `input.object` given a non-JSON string binds its children's defaults. When the shape
|
|
447
|
+
must hold, mark an object child `required: true` or check it with `s.precondition`.
|
|
448
|
+
|
|
449
|
+
**Validate input at the boundary.** Field types don't enforce arbitrary
|
|
450
|
+
rules, and `s.precondition` raises a **status-bearing** error a client can detect via
|
|
451
|
+
`res.ok` — unlike `s.throw`, which returns 200 with an error body. `error_type` picks the
|
|
452
|
+
status: `badrequest`/`inputerror` → 400, `unauthorized` → 401, `accessdenied` → 403,
|
|
453
|
+
`notfound` → 404, `toomanyrequests` → 429, `standard` (the default) → 500.
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
s.precondition({
|
|
457
|
+
// `fl.regex_test` is PATTERN-piped: the piped value is the regex and the arg is the
|
|
458
|
+
// text tested — the reverse of `istarts_with`. Build the pattern with `c.regex(...)`,
|
|
459
|
+
// which delimiter-wraps it (a bare `c.text("^…")` is an invalid PCRE matching nothing).
|
|
460
|
+
expr: expr(withFilters(c.regex("^https?://", "i"), fl.regex_test(inp("url"))), "=", c.bool(true)),
|
|
461
|
+
error_type: "badrequest",
|
|
462
|
+
error: c.text("url must be an http(s) URL"),
|
|
463
|
+
})
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Normalize on the input, not in the stack.** `methods` run at bind, before your stack, so
|
|
467
|
+
`input.email({ methods: ["lower"] })` makes `inp("email")` read already-normalized. Don't
|
|
468
|
+
reroll `trim`/`lower`/`upper` into a var.
|
|
469
|
+
|
|
470
|
+
**Email/password auth.** The trap: `input.password()` **hashes on bind**, so a password
|
|
471
|
+
typed that way is already a hash before your stack runs, and `check_password` then compares
|
|
472
|
+
hash against hash — login always fails. Take the password as **plain text** and let the
|
|
473
|
+
`f.password` *column* hash it on write; `check_password` compares the plaintext submission
|
|
474
|
+
against the stored hash.
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// Signup — plaintext in; the f.password COLUMN hashes on write.
|
|
478
|
+
query({ name: "signup", verb: "POST", apiGroup: authApi,
|
|
479
|
+
input: { email: input.email({ required: true }), name: input.text(),
|
|
480
|
+
password: input.text({ required: true, methods: ["min:6"] }) }, // NOT input.password()
|
|
481
|
+
stack: [
|
|
482
|
+
s.db.add({ table: usersTbl,
|
|
483
|
+
row: { email: inp("email"), name: inp("name"), password: inp("password") }, as: "user" }),
|
|
484
|
+
s.security.create_auth_token({ table: usersTbl, id: ref("user.id"), as: "token" }),
|
|
485
|
+
],
|
|
486
|
+
response: ref("token") });
|
|
487
|
+
|
|
488
|
+
// Login — plaintext compared against the stored hash.
|
|
489
|
+
query({ name: "login", verb: "POST", apiGroup: authApi,
|
|
490
|
+
input: { email: input.email({ required: true }),
|
|
491
|
+
password: input.text({ required: true }) }, // NOT input.password()
|
|
492
|
+
stack: [
|
|
493
|
+
s.db.get({ table: usersTbl, fieldName: "email", fieldValue: inp("email"),
|
|
494
|
+
output: ["id", "email", "password"], as: "user" }),
|
|
495
|
+
s.precondition({ expr: expr(ref("user"), "!=", c.null()),
|
|
496
|
+
error_type: "accessdenied", error: c.text("Invalid email or password.") }),
|
|
497
|
+
s.security.check_password({ text_password: inp("password"), // plaintext
|
|
498
|
+
hash_password: ref("user.password"), as: "ok" }),
|
|
499
|
+
s.precondition({ expr: expr(ref("ok"), "=", c.bool(true)),
|
|
500
|
+
error_type: "accessdenied", error: c.text("Invalid email or password.") }),
|
|
501
|
+
s.security.create_auth_token({ table: usersTbl, id: ref("user.id"), as: "token" }),
|
|
502
|
+
],
|
|
503
|
+
response: ref("token") });
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Reach for `input.password()` only when you specifically want its bind-time hash **and** are
|
|
507
|
+
not also feeding it to `check_password`.
|
|
508
|
+
|
|
509
|
+
## Middleware, request history and env vars
|
|
510
|
+
|
|
511
|
+
A `middleware({...})` is reusable logic (`input`/`stack`/`response` + `resultStrategy:
|
|
512
|
+
"merge"|"replace"` + `exceptionPolicy`). To run one, *attach* it with a host's
|
|
513
|
+
`middleware: { pre, post }` field on `query`/`apiGroup`/`defineFunction`/`task`/`tool`
|
|
514
|
+
(not triggers). Prefer a def handle over a bare name, the same rule as `auth`/`apiGroup`
|
|
515
|
+
references; `{ middleware: mw, active: false }` keeps an entry but disables it.
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
query({
|
|
519
|
+
name: "get_user", verb: "GET", apiGroup: blog,
|
|
520
|
+
middleware: { pre: [rateLimit], post: [audit] },
|
|
521
|
+
stack: [/* ... */], response: ref("user"),
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
- **`exceptionPolicy` decides whether a guard is a guard.** Xano SDK always writes this
|
|
526
|
+
field and defaults it to **`"rethrow"`**, so a rate limit or auth check you author here
|
|
527
|
+
aborts the request as intended even with the field unspelled. `"rethrow"` surfaces the
|
|
528
|
+
authored `error`/status (a tripped `s.redis.ratelimit` → 429) while still running `post`;
|
|
529
|
+
`"critical"` is the same but skips the `post` chain. That is the only difference.
|
|
530
|
+
`"silent"` swallows the throw and lets the request through — advisory middleware only.
|
|
531
|
+
The **engine** falls back to `"silent"` when the field is absent *entirely*, which Xano SDK
|
|
532
|
+
never emits; a middleware created in the workspace UI or carried in from a pulled
|
|
533
|
+
workspace can be in that state, and there an unspelled guard is not enforced.
|
|
534
|
+
- **Inheritance is override, not merge.** Providing a phase overrides it; omitting a phase
|
|
535
|
+
inherits the parent tier's chain, resolved at request time **Query → API Group →
|
|
536
|
+
Workspace**. `pre: middleware.clear()` overrides a phase with nothing.
|
|
537
|
+
- **Setting `workspaceConfig.middleware` at all emits the whole map.** Any host/phase you
|
|
538
|
+
don't list is emitted empty, which **clears** that tier on deploy. Omit the field entirely
|
|
539
|
+
to leave the workspace's existing middleware untouched. The same wholesale rule applies to
|
|
540
|
+
`datasources`.
|
|
541
|
+
- **A public host has no caller identity for `auth()` to resolve**, and a `pre` middleware
|
|
542
|
+
runs after auth resolution. A rate limit keyed by `auth("id")` on a public endpoint does
|
|
543
|
+
not degrade to a shared bucket — the request **fails with a 403 on the first call**, under
|
|
544
|
+
`max`, with the host never running. Key off `sys.remoteIp()` instead.
|
|
545
|
+
`export()` warns on direct attachment of an `auth()`-keyed middleware to a host where
|
|
546
|
+
`auth()` may be null.
|
|
547
|
+
- **`resultStrategy` applies to the accumulator of the phase the middleware is attached
|
|
548
|
+
to, and those are two different things.** In **`post`** the accumulator is the host's
|
|
549
|
+
**result**, so a returned `response` changes what the CALLER receives. In **`pre`** it is
|
|
550
|
+
the host's **request inputs**, so a returned `response` changes what the HOST receives.
|
|
551
|
+
`merge` folds key-by-key, `replace` substitutes wholesale; either way the next entry in
|
|
552
|
+
the chain sees the updated value. A `pre` + `replace` middleware therefore discards every
|
|
553
|
+
input the caller sent that it does not re-emit, and a dropped input becomes unreadable —
|
|
554
|
+
a 500, even where the input is declared `required: false`, because defaulting has already
|
|
555
|
+
happened by the time the override lands.
|
|
556
|
+
- A `resultStrategy: "replace"` middleware attached `post` rewrites the response at runtime,
|
|
557
|
+
which `InferResponse` can't see — declare `responseShape` on the endpoint.
|
|
558
|
+
- `workspaceConfig` also carries `realtime`, `documentation`, and `swagger`, which are
|
|
559
|
+
server-shaped and carried verbatim rather than authored. `realtime` there is the **legacy**
|
|
560
|
+
workspace-level block, not the realtime primitives you author.
|
|
561
|
+
|
|
562
|
+
**The canonical rate-limit middleware.** Build the per-user key with the filter chain
|
|
563
|
+
(`"prefix" + auth("id")` doesn't exist):
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
const writeRl = middleware({
|
|
567
|
+
name: "write_rl",
|
|
568
|
+
exceptionPolicy: "rethrow", // a tripped limit must abort (silent would let it through)
|
|
569
|
+
stack: [
|
|
570
|
+
s.redis.ratelimit({
|
|
571
|
+
key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), // "rl:write:<id>"
|
|
572
|
+
max: c.int(10), ttl: c.int(30), error: c.text("Too fast."),
|
|
573
|
+
}),
|
|
574
|
+
],
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
query({ name: "create_post", verb: "POST", apiGroup: blog, auth: users, // authed ⇒ per-user
|
|
578
|
+
middleware: { pre: [writeRl] }, stack: [/* ... */], response: ref("post") });
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
On a **public** endpoint key off the client IP instead — `sys.remoteIp()` — since
|
|
582
|
+
`auth("id")` does not resolve there and the request 403s. And note the **shared-bucket rule**: co-attaching one middleware
|
|
583
|
+
object to N hosts means all N share the same key and therefore one counter, so `max: 10` is
|
|
584
|
+
a global budget across them. Vary the key (fold the host name into the prefix) for an
|
|
585
|
+
independent limit per host.
|
|
586
|
+
|
|
587
|
+
**Reading the request, or the response, in a middleware.** `s.util.get_all_input({ as: "payload" })`
|
|
588
|
+
binds a **`{ type, vars }` envelope whose `vars` depends on the phase** the middleware is
|
|
589
|
+
attached to:
|
|
590
|
+
|
|
591
|
+
| phase | `vars` holds | read a field at |
|
|
592
|
+
|---|---|---|
|
|
593
|
+
| `pre` | the host's request inputs | `ref("payload.vars.<field>")` |
|
|
594
|
+
| `post` | `{ status, result }` — the host's outcome; plus `payload` when the host failed with an error payload | `ref("payload.vars.result.<field>")` |
|
|
595
|
+
|
|
596
|
+
The un-nested path (`ref("payload.<field>")`) is the usual cause of an `Unable to locate var`
|
|
597
|
+
500 in `pre`. In `post` the request path fails the same way — **after the host already ran and
|
|
598
|
+
its writes landed**, so under the default `rethrow` the caller gets a 500 for work that
|
|
599
|
+
succeeded. `export()` warns on it. To read the request in a `post` middleware, use
|
|
600
|
+
`s.util.get_raw_input({ as: "body" })`, which returns the request body:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
middleware({
|
|
604
|
+
name: "audit_post",
|
|
605
|
+
resultStrategy: "merge",
|
|
606
|
+
stack: [
|
|
607
|
+
s.util.get_all_input({ as: "payload" }), // { type: "post", vars: { status, result } }
|
|
608
|
+
s.util.get_raw_input({ as: "body" }), // the request body
|
|
609
|
+
s.set_var("audit", obj({ id: ref("payload.vars.result.id"), note: ref("body.note") })),
|
|
610
|
+
],
|
|
611
|
+
response: { audit: ref("audit") },
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**Request history** — the per-object execution trace behind Xano's debugger, authored as a
|
|
616
|
+
single scalar `history` field: `false` off, `true` on at the default depth, a **number** =
|
|
617
|
+
capture depth (statement executions recorded per record, *not* records retained), `"all"` =
|
|
618
|
+
unlimited. **Omitting it inherits**; any value stops inheriting. Inheritance resolves
|
|
619
|
+
**object → container → workspace** (a query from its API group, a tool from its
|
|
620
|
+
toolset/agent, everything else straight from the workspace). Per-kind defaults when
|
|
621
|
+
inheriting: query / task / tool capture **on**; function / trigger / middleware **off**.
|
|
622
|
+
`workspaceConfig.history` is wholesale in the same way the middleware map is.
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
query({ name: "get_user", verb: "GET", history: 100 }); // capture, depth cap 100
|
|
626
|
+
apiGroup({ name: "blog", history: false }); // default for its queries
|
|
627
|
+
workspaceConfig({ history: { query: 100, trigger: "all" } }); // name inherited from workspace("…")
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
**Workspace environment variables** — author them as a name→value map on the workspace
|
|
631
|
+
object; read them at request time with `env("NAME")`:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
workspaceConfig({
|
|
635
|
+
name: "my-app",
|
|
636
|
+
env: {
|
|
637
|
+
STRIPE_KEY: process.env.STRIPE_KEY!, // sourced from the deploy environment
|
|
638
|
+
APP_BASE_URL: "https://my-app.example.com", // a plain config value
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
**Values are secrets.** Prefer sourcing them from the deploy environment over committing
|
|
644
|
+
literals, and don't commit a compiled bundle holding real ones. Deploying sets the vars you
|
|
645
|
+
declare; omit the field to leave the workspace's existing env untouched.
|
|
646
|
+
|
|
647
|
+
Better still, keep the value out of the repo entirely: declare the NAME here with an empty
|
|
648
|
+
value and keep the value in `xano/.env`.
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
workspaceConfig({ name: "my-app", env: { STRIPE_KEY: "" } });
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
# xano/.env — gitignored, preserved across a pull, and read with no flag by every
|
|
656
|
+
# command that compiles a bundle (deploy, export, preflight).
|
|
657
|
+
STRIPE_KEY=sk_live_...
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`xano/.env.example` is generated beside it listing the declared names, and `xanosdk env pull`
|
|
661
|
+
fills `xano/.env` from a running backend after confirming. Two flags override the default:
|
|
662
|
+
|
|
663
|
+
```bash
|
|
664
|
+
xanosdk deploy --backend-env-file ci.env # REPLACES the default (CI has no xano/.env)
|
|
665
|
+
xanosdk deploy --env-var STRIPE_KEY=sk_live_... # one value, beats both files
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
A deploy REPLACES the workspace's env set, so a declared name that no file and no flag
|
|
669
|
+
supplies would clear the live value — `deploy` refuses instead, naming the variables;
|
|
670
|
+
`--allow-empty-env=STRIPE_KEY` clears that one on purpose. `export` and `preflight` report
|
|
671
|
+
rather than refusing: one writes a file, the other imports into a throwaway tenant.
|
|
672
|
+
|
|
673
|
+
### The documentation token
|
|
674
|
+
|
|
675
|
+
The token gating a hosted doc site is a secret too, and it lives in `xano/.secrets.json` —
|
|
676
|
+
gitignored, beside `xano/.env` but not in it. Source declares only the GATE:
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
workspaceConfig({ documentation: { require_token: true } })
|
|
680
|
+
apiGroup({ name: "Public API", swagger: true, documentation: { require_token: true } })
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
That is the whole declaration. There is no variable name to pick, because a documentation token
|
|
684
|
+
has no name — it belongs to an object (this workspace, that API group), and the sidecar stores
|
|
685
|
+
it under that object's identity. An API group is keyed by its **guid**, not its name, so two
|
|
686
|
+
groups that legitimately share a name keep their own tokens.
|
|
687
|
+
|
|
688
|
+
A literal `token` in source fails the export — there is no migration path, because declaring
|
|
689
|
+
the gate covers every use and the literal form's only distinguishing property is that it commits
|
|
690
|
+
a secret. `xanosdk pull` writes the value to the sidecar, reports every scope it carried, and
|
|
691
|
+
every build reads it back, so pull-then-deploy restores the gate with no step in between.
|
|
692
|
+
|
|
693
|
+
A gate you write here is the other direction: there is no live value to pull, because the
|
|
694
|
+
object has never had one. `xanosdk secrets fill` mints one for every declared gate the sidecar
|
|
695
|
+
has no value for, and leaves every value it already holds alone:
|
|
696
|
+
|
|
697
|
+
```bash
|
|
698
|
+
xanosdk secrets fill # or `xanosdk secrets fill ./xano/index.ts`
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
It writes nothing when every gate is already filled, so it is safe to re-run. A minted token
|
|
702
|
+
gates nothing until a `deploy` sends it.
|
|
703
|
+
|
|
704
|
+
This token is **not** a backend env var. It never reaches the workspace's own env, and `env(...)`
|
|
705
|
+
in a stack cannot read it.
|
|
706
|
+
|
|
707
|
+
`deploy` refuses on a declared gate nothing supplies, with `--allow-empty-doc-token=<scope>` as
|
|
708
|
+
the per-scope opt-out — and unlike its backend sibling, that opt-out *opens* something: it
|
|
709
|
+
clears the target's gate and makes those docs publicly readable. Without the flag, an
|
|
710
|
+
unsupplied gate emits no `documentation` block at all — which leaves the workspace's gate
|
|
711
|
+
alone, but would clear a group's, so a group that publishes docs and declares a gate with no
|
|
712
|
+
token fails the export on every command rather than writing a bundle no later refusal could
|
|
713
|
+
catch.
|
|
714
|
+
A merge (`--keep-data`, or `--to` without `--replace`) never writes the workspace's block, so
|
|
715
|
+
it does not require the workspace's token, and the opt-out is not applied there; a group's
|
|
716
|
+
gate is still required, since a merge writes a group's docs.
|
|
717
|
+
|
|
718
|
+
See [Environment & secrets](environment.md) for the CI inputs (`--secrets-file`, `--doc-token`)
|
|
719
|
+
and what a pull does when it cannot confirm the file is ignored.
|
|
720
|
+
|
|
721
|
+
**The two blocks behave oppositely on omission, and this is the part that bites.** Omitting
|
|
722
|
+
`documentation` from the workspace config emits no key, so a deploy leaves the target's block
|
|
723
|
+
alone. Omitting it from an API group does *not*: an absent key on a group is written as the
|
|
724
|
+
engine default on import, which clears that group's gate. So a group you want gated must say
|
|
725
|
+
so every time. `export` warns once per build naming every group whose docs the deploy would
|
|
726
|
+
leave publicly readable, and `--strict` turns that into a failed build.
|
|
727
|
+
|
|
728
|
+
**Across environments.** A `release` merge is ADD-ONLY and matched by name: a name the target
|
|
729
|
+
already has keeps the target's value, so one release can be promoted to several environments
|
|
730
|
+
without carrying the creds it was cut with. Set each target's env once and the release's own
|
|
731
|
+
values stay inert. Two consequences to plan around:
|
|
732
|
+
|
|
733
|
+
- A release carries no env values. A **newly declared** name the target does not hold yet
|
|
734
|
+
is not created by `promote` or `tenant deploy`: the release's objects read it as null there
|
|
735
|
+
until it is set. Both commands name every env var the release reads that the target lacks,
|
|
736
|
+
before the confirmation, with the `printf %s "$VALUE" | xanosdk env set NAME --to … --yes`
|
|
737
|
+
that sets it.
|
|
738
|
+
- `deploy --to workspace --replace` rebuilds the workspace, so the env set becomes exactly what the config
|
|
739
|
+
declares. Every env var the target holds that your config does not declare is DROPPED —
|
|
740
|
+
including ones set through the UI or by an earlier release. A replace names those before it
|
|
741
|
+
runs and asks to confirm, because the value is not recoverable from anything the project
|
|
742
|
+
holds: by definition the project never declared it. Declare the name to keep it, or merge
|
|
743
|
+
without `--replace`.
|
|
744
|
+
|
|
745
|
+
> ⚠️ The refusal is part of the COMPILE, so `xanosdk deploy --bundle <path>` does not get it:
|
|
746
|
+
> a pre-built bundle carries whatever env it was built with, and deploying it sends exactly
|
|
747
|
+
> that. A build-once-deploy-the-artifact pipeline therefore has to resolve its values at
|
|
748
|
+
> BUILD time — pass `--backend-env-file`/`--env-var` to the `export` that produces the bundle, and
|
|
749
|
+
> treat that export's "declared names are unsupplied" warning as the gate — `export --strict`
|
|
750
|
+
> fails on it — because nothing re-checks it later.
|
|
751
|
+
|
|
752
|
+
A value passed with `--env-var` exists only for the length of that process, and no value is
|
|
753
|
+
ever printed back. Declaring the name is what keeps it checkable: `typedEnv(config)` turns the
|
|
754
|
+
declared names into properties, so `E.STRIPE_KEY` autocompletes and `E.STRIP_KEY` is a
|
|
755
|
+
compile error, and `export()` warns (`stack.env-undeclared`) on any `env()` naming something
|
|
756
|
+
outside the set.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
const E = typedEnv(wsConfig);
|
|
760
|
+
s.api.request({ url: c.text("https://api.stripe.com/v1/charges"), headers: { Authorization: E.STRIPE_KEY } });
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
A name that does not exist resolves to **null** rather than erroring, which is why this
|
|
764
|
+
matters: a typo deploys clean, the request succeeds, and the failure surfaces somewhere
|
|
765
|
+
downstream of the missing value.
|
|
766
|
+
|
|
767
|
+
Do NOT confuse this with `deploy --static-env`, which bakes PUBLIC config into the HTML a
|
|
768
|
+
browser downloads. One is a workspace secret; the other is visible to everyone.
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
## Receiving a webhook
|
|
772
|
+
|
|
773
|
+
Every SaaS integration needs the same four steps, and two of them are inert unless they come
|
|
774
|
+
before the thing they protect. All the pieces exist already — what follows is the ORDER.
|
|
775
|
+
|
|
776
|
+
```ts
|
|
777
|
+
export const webhookEvents = table({
|
|
778
|
+
name: "webhook_events",
|
|
779
|
+
schema: { provider_event_id: f.text({ required: true }), payload: f.json() },
|
|
780
|
+
// The durable duplicate guard: a replayed event id fails the insert.
|
|
781
|
+
index: [{ type: "unique", fields: [{ name: "provider_event_id" }] }],
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
export const stripeWebhook = query({
|
|
785
|
+
name: "stripe_webhook",
|
|
786
|
+
verb: "POST",
|
|
787
|
+
apiGroup: api,
|
|
788
|
+
// 1. No declared `input`. The body must be read RAW — anything the input
|
|
789
|
+
// layer parses and re-serializes is a different byte string, and the
|
|
790
|
+
// signature is over the bytes the provider actually sent. `encoding:
|
|
791
|
+
// "none"` binds those bytes as one string; the default (`"json"`) binds a
|
|
792
|
+
// JSON body as an object, which `hmac_sha256` refuses (500).
|
|
793
|
+
stack: [
|
|
794
|
+
s.util.get_raw_input({ as: "raw", encoding: "none" }),
|
|
795
|
+
s.set_var("headers", sys.httpHeaders()),
|
|
796
|
+
|
|
797
|
+
// 2. Recompute the signature over the raw body with the shared secret…
|
|
798
|
+
s.set_var("expected", withFilters(ref("raw"), fl.hmac_sha256(env("STRIPE_WEBHOOK_SECRET")))),
|
|
799
|
+
// …and compare it BEFORE anything else reads the payload. A guard that
|
|
800
|
+
// runs after the work is not a guard.
|
|
801
|
+
s.precondition({
|
|
802
|
+
// Header keys arrive Title-Cased whatever the provider sent, so a
|
|
803
|
+
// lowercase "x-signature" reads null in production.
|
|
804
|
+
expr: expr(withFilters(ref("headers"), fl.get("X-Signature")), "=", ref("expected")),
|
|
805
|
+
error_type: "unauthorized",
|
|
806
|
+
error: c.text("Bad signature."),
|
|
807
|
+
}),
|
|
808
|
+
|
|
809
|
+
s.set_var("event", withFilters(ref("raw"), fl.json_decode())),
|
|
810
|
+
|
|
811
|
+
// 3. Idempotency. `create_only` makes this a CLAIM rather than a write: it
|
|
812
|
+
// succeeds exactly once per key, so a replay — and providers do retry —
|
|
813
|
+
// takes the false branch instead of processing the event twice.
|
|
814
|
+
s.redis.set({
|
|
815
|
+
key: withFilters(c.text("wh:"), fl.concat(ref("event.id"))),
|
|
816
|
+
data: c.bool(true),
|
|
817
|
+
ttl: c.int(every("7d")),
|
|
818
|
+
create_only: c.bool(true),
|
|
819
|
+
as: "claimed",
|
|
820
|
+
}),
|
|
821
|
+
s.conditional({
|
|
822
|
+
when: expr(ref("claimed"), "=", c.bool(true)),
|
|
823
|
+
then: [s.db.add({ table: webhookEvents, row: { provider_event_id: ref("event.id"), payload: ref("event") } })],
|
|
824
|
+
}),
|
|
825
|
+
],
|
|
826
|
+
|
|
827
|
+
// 4. Answer 200 either way. A provider that sees a non-2xx retries, and a
|
|
828
|
+
// duplicate is not a failure — returning an error for one is how a retry
|
|
829
|
+
// storm starts.
|
|
830
|
+
response: { received: c.bool(true) },
|
|
831
|
+
});
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
Four things that are easy to get wrong:
|
|
835
|
+
|
|
836
|
+
- **Declaring `input` breaks the signature.** The raw body and the re-serialized one differ
|
|
837
|
+
in whitespace and key order, so the HMAC will not match and every delivery is rejected.
|
|
838
|
+
- **The header name is whatever the provider uses** (`X-Signature`, `Stripe-Signature`,
|
|
839
|
+
`X-Hub-Signature-256`, …) — read it Title-Cased, however the provider spells it — and several providers prefix the digest or pack a timestamp
|
|
840
|
+
alongside it. Read the provider's spec for the exact string being signed — some sign
|
|
841
|
+
`timestamp + "." + body`, not the body alone.
|
|
842
|
+
- **`create_only` is the whole idempotency mechanism.** Without it the write always succeeds
|
|
843
|
+
and the branch always runs, so the guard reads as present and does nothing.
|
|
844
|
+
- **Redis is an instance capability.** On an instance without cache support every
|
|
845
|
+
`s.redis.*` statement fails at request time; `xanosdk export` lists what a bundle needs.
|
|
846
|
+
The unique index on `webhook_events.provider_event_id` above is the durable fallback — the insert fails on a
|
|
847
|
+
duplicate rather than being skipped, so wrap it in `s.try_catch` if you take that route.
|
|
848
|
+
|
|
849
|
+
Give the endpoint no `auth`: the signature IS the authentication, and a webhook caller has
|
|
850
|
+
no Xano identity to present.
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
## Seed data
|
|
855
|
+
|
|
856
|
+
Give a table `seed` rows and they ship into the database on deploy — so a fresh
|
|
857
|
+
environment comes up with lookup tables, demo content, or fixtures already in place,
|
|
858
|
+
not empty:
|
|
859
|
+
|
|
860
|
+
```ts
|
|
861
|
+
const product = table({
|
|
862
|
+
name: "product",
|
|
863
|
+
schema: {
|
|
864
|
+
sku: f.text({ required: true }),
|
|
865
|
+
name: f.text({ required: true }),
|
|
866
|
+
price: f.decimal(),
|
|
867
|
+
tags: f.text({ array: true }),
|
|
868
|
+
},
|
|
869
|
+
// Rows are validated against the column types before deploy. A column without
|
|
870
|
+
// `required: true` may be omitted (the engine applies its default). Omit `id` and
|
|
871
|
+
// rows are keyed for you — 1..N for an int PK, a stable uuid for a uuid PK (or
|
|
872
|
+
// set `id` on every row); a bad value
|
|
873
|
+
// or unknown column is a loud error, never a silent drop.
|
|
874
|
+
seed: [
|
|
875
|
+
{ sku: "SKU-001", name: "Aeron Chair", price: 1395, tags: ["furniture", "ergonomic"] },
|
|
876
|
+
{ sku: "SKU-002", name: "Standing Desk", price: 599, tags: ["furniture"] },
|
|
877
|
+
],
|
|
878
|
+
});
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
Pinning an `id` this way is a `seed` property, not a general bulk-insert one: the runtime
|
|
882
|
+
statement `s.db.bulk.add` **drops `id` from every row** unless you pass `allowIdField: true`,
|
|
883
|
+
assigning the next sequence value instead.
|
|
884
|
+
|
|
885
|
+
Its sibling `s.db.bulk.delete` has the mirror-image rule: a filter that constrains nothing
|
|
886
|
+
matches every row, so a `where`-less delete is a truncate. It **throws** unless you say so
|
|
887
|
+
with `allRows: true`, which wipes the table and returns the deleted count — the wipe can no
|
|
888
|
+
longer be reached by forgetting an argument. Reach for `s.db.truncate({ table, reset: true })`
|
|
889
|
+
when the id sequence should restart too.
|
|
890
|
+
|
|
891
|
+
Deploy is a full replace, so re-deploying re-seeds cleanly — no duplicate rows. Seed
|
|
892
|
+
data travels only in the deploy package (resolved at deploy time); it never enters the
|
|
893
|
+
compiled workspace bundle.
|
|
894
|
+
|
|
895
|
+
For data in a file, use `seedFile`:
|
|
896
|
+
|
|
897
|
+
```ts
|
|
898
|
+
seed: seedFile("./products.seed.json", import.meta.url),
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
The path resolves against the file that declares the table, and it is read with `node:fs`
|
|
902
|
+
at deploy time. Note the tradeoff: inline rows are typed against the table schema at compile
|
|
903
|
+
time, while a `seedFile`/thunk seed is opaque to the typechecker — `xanosdk export`/`deploy`
|
|
904
|
+
validates it instead, naming the row index, the offending column and the table's known
|
|
905
|
+
columns. A thunk (`seed: () => import("./products.seed.json")`) also works and is
|
|
906
|
+
the right shape for *computed* seeds — but be aware it does **not** keep seed values out of
|
|
907
|
+
a frontend build: the `import()` lives in your module, so a bundler emits the JSON as a
|
|
908
|
+
served chunk, and any frontend that imports a def whose module graph reaches that table
|
|
909
|
+
ships the seed to the browser. `seedFile` stores a path string, which a bundler has nothing
|
|
910
|
+
to follow.
|
|
911
|
+
|
|
912
|
+
Either way, keep secrets out of `seed` — it is throwaway fixture data for disposable
|
|
913
|
+
environments. As a backstop, `xanosdk deploy <entry> --static <dir>` and `xanosdk deploy <entry> --to workspace --static <dir>`
|
|
914
|
+
refuse to publish a frontend build containing seed values from password columns or columns
|
|
915
|
+
your schema marks `access: "internal"` or `sensitive`. If the data is deliberately public (a
|
|
916
|
+
demo login the frontend shows), declare it on the table: `publicSeed: ["password"]`.
|
|
917
|
+
|
|
918
|
+
Typing is unaffected by the form you choose — the table's row type and column names stay
|
|
919
|
+
inferred.
|
|
920
|
+
|
|
921
|
+
---
|