@xanots/sdk 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +34 -1
  3. package/dist/agent-file-refresh-IJ7GN2FJ.js +17 -0
  4. package/dist/bin.js +12 -153
  5. package/dist/{capture-HUV5BNTC.js → capture-7PO6SGB4.js} +2 -2
  6. package/dist/{chunk-CMZLPTGW.js → chunk-7SISR3CS.js} +2 -2
  7. package/dist/{chunk-7DKX2SPN.js → chunk-ACQCK4DT.js} +4 -4
  8. package/dist/{chunk-XQ22GLYS.js → chunk-AJ7JQ4OU.js} +5 -5
  9. package/dist/{chunk-ZO3HJOCJ.js → chunk-ALNQCWAW.js} +2 -2
  10. package/dist/{chunk-55VHDNW5.js → chunk-BPEEGJJA.js} +108 -3
  11. package/dist/{chunk-U3G2UW65.js → chunk-CYJ7R3AD.js} +3 -3
  12. package/dist/{agent-file-refresh-6M5M7T24.js → chunk-D6OGU2AI.js} +5 -6
  13. package/dist/{chunk-3NFUWXOC.js → chunk-DUC3PXDG.js} +142 -45
  14. package/dist/{chunk-HQ2CBRVI.js → chunk-EAIR6VUL.js} +10 -8
  15. package/dist/chunk-ERQZFWIW.js +22 -0
  16. package/dist/{chunk-TQUO2OXY.js → chunk-FN4OQT2J.js} +4 -4
  17. package/dist/{chunk-6USV65XA.js → chunk-KGNJM4LN.js} +2 -2
  18. package/dist/{chunk-HJPTWBLH.js → chunk-MFIHIS6B.js} +2 -2
  19. package/dist/{chunk-F6JCFRKO.js → chunk-PXXLBXOP.js} +2 -2
  20. package/dist/{chunk-YZ4GU6F5.js → chunk-QUUB7HYK.js} +5 -838
  21. package/dist/{chunk-W2G2WPTB.js → chunk-SWIXWJIY.js} +3 -3
  22. package/dist/{chunk-76QBEIGO.js → chunk-T4XPCJRF.js} +3 -2
  23. package/dist/chunk-T6N3VMMO.js +173 -0
  24. package/dist/{chunk-2V4YE6QC.js → chunk-VKD3EBKG.js} +112 -21
  25. package/dist/{chunk-BT2CSEC5.js → chunk-VWGJQTNA.js} +3 -3
  26. package/dist/chunk-WGDAOOXG.js +845 -0
  27. package/dist/{chunk-4YMD2OOZ.js → chunk-XHEXOES3.js} +1 -1
  28. package/dist/{chunk-4HT3BNZ7.js → chunk-YUBJLB6G.js} +10 -2
  29. package/dist/{chunk-5L4X5LS6.js → chunk-YUPQOLFX.js} +77 -2
  30. package/dist/{chunk-DYCLVQXW.js → chunk-YYSJAAJM.js} +25 -1
  31. package/dist/{chunk-P3TTMWUP.js → chunk-ZOYMZZ3S.js} +8 -1
  32. package/dist/chunk-ZSYZTGJH.js +81 -0
  33. package/dist/cli.d.ts +6 -0
  34. package/dist/cli.js +8 -8
  35. package/dist/{codegen-command-FIADGXRN.js → codegen-command-Z7WQMOJ7.js} +21 -20
  36. package/dist/{completion-BKAFCZBE.js → completion-4GYN752B.js} +2 -2
  37. package/dist/{deploy-command-4R6BYC6G.js → deploy-command-M2S4HHLC.js} +26 -26
  38. package/dist/{env-target-XWS2ZZ2Y.js → env-target-GSFCYHKZ.js} +6 -6
  39. package/dist/{ephemeral-command-JL4TPIRQ.js → ephemeral-command-6WJW7LP6.js} +24 -24
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +13 -7
  42. package/dist/init-command-GJUPWTKA.js +30 -0
  43. package/dist/internal.d.ts +2 -2
  44. package/dist/internal.js +50 -5
  45. package/dist/{io-AMIKRLPC.js → io-7VIA5SON.js} +3 -3
  46. package/dist/{live-diff-RXSJCVJ7.js → live-diff-FP4SFNT4.js} +2 -2
  47. package/dist/{lock-3CVKALKT.js → lock-KXOJIGCG.js} +2 -2
  48. package/dist/{lock-commands-XOYS75YQ.js → lock-commands-7IH7RSSP.js} +9 -9
  49. package/dist/{login-command-ACJF6KWQ.js → login-command-QM5SBBI2.js} +2 -2
  50. package/dist/{logout-command-MX3MJS5U.js → logout-command-THPASOHM.js} +2 -2
  51. package/dist/{loop-SAWAOUFO.js → loop-7SAIGRCZ.js} +3 -3
  52. package/dist/{marketplace-command-UVV3XAOL.js → marketplace-command-463DT7O7.js} +11 -25
  53. package/dist/meta-client-OW5WKWW7.js +1 -1
  54. package/dist/node.d.ts +2 -2
  55. package/dist/node.js +18 -12
  56. package/dist/onboard-command-EHOHKQQU.js +36 -0
  57. package/dist/{profile-command-SWJ3SPKR.js → profile-command-EUWPVWED.js} +6 -6
  58. package/dist/{release-command-HZUY2XZX.js → release-command-M4CVNTWL.js} +25 -25
  59. package/dist/{sandbox-details-command-HJE5SPVG.js → sandbox-details-command-BRII3QHG.js} +5 -5
  60. package/dist/{sandbox-export-command-QCJY4GMV.js → sandbox-export-command-PCZ5FL4U.js} +8 -8
  61. package/dist/scaffold.js +4 -2
  62. package/dist/{store-g45zwB33.d.ts → store-BJONDJoZ.d.ts} +171 -2
  63. package/dist/{test-command-72Y5S22H.js → test-command-U4G5Y3UM.js} +10 -10
  64. package/dist/upgrade-command-CKJ4BS2P.js +177 -0
  65. package/dist/{validate-command-KDGH537H.js → validate-command-S5N37JRP.js} +12 -12
  66. package/dist/{workspace-command-ICSI6PKO.js → workspace-command-RALSEJSW.js} +28 -27
  67. package/dist/workspace-export-AJMGN3CQ.js +1 -1
  68. package/guides/README.md +30 -0
  69. package/guides/authoring.md +642 -0
  70. package/guides/cli.md +191 -0
  71. package/guides/codegen.md +83 -0
  72. package/guides/coverage.md +67 -0
  73. package/guides/deploying.md +340 -0
  74. package/guides/environment.md +132 -0
  75. package/guides/object-kinds.md +376 -0
  76. package/guides/project-structure.md +43 -0
  77. package/guides/scaffold.md +198 -0
  78. package/guides/typed-frontend.md +201 -0
  79. package/llms/kinds-knowledge.md +20 -0
  80. package/llms/object-kinds.md +1 -0
  81. package/llms-full.txt +25 -2
  82. package/llms.txt +3 -2
  83. package/manifest.json +26 -3
  84. package/package.json +5 -2
  85. package/dist/.build-fingerprint +0 -1
  86. package/dist/chunk-WUSKBXXD.js +0 -25
  87. package/dist/init-command-LOK75W64.js +0 -29
@@ -0,0 +1,642 @@
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). Both are usable wherever a column name
25
+ is expected and both appear in `InferRow`. The standard indexes — `primary(id)`,
26
+ `btree(created_at desc)`, plus `gin(xdo)` when the table stores fields as JSON —
27
+ auto-prepend, de-duped against your own. Declare yours as
28
+ `{ type, fields: [{ name, op? }] }`; `"unique"` is shorthand for `"btree|unique"`.
29
+ - **`use_xdo` picks the storage mode** — every field as JSON under the internal `xdo` column,
30
+ or a real Postgres column per field. It is a workspace setting (default `false`) each table
31
+ mirrors, overridable per table with `table({ useXdo })`, resolved at `export()` so the two
32
+ can register in any order.
33
+
34
+ ## Statements, values & inputs
35
+
36
+ The `stack` of a function/query/tool is a list of statements, all reachable through one
37
+ discoverable, typed namespace — `s`:
38
+
39
+ ```ts
40
+ stack: [
41
+ s.set_var("total", c.int(0)),
42
+ s.math.add({ name: "total", value: c.int(5) }),
43
+ s.array.find({ as: "hit", expr: ref("items"), if: expr(ref("$this"), "=", c.int(1)) }),
44
+ s.conditional({ when: expr(ref("total"), ">", c.int(0)), then: [s.return(ref("total"))] }),
45
+ s.function.run({ fn: getUser, as: "u", input: { id: ref("total") } }),
46
+ ]
47
+ ```
48
+
49
+ Tab-complete `s.` to explore. Each declarative statement takes one typed args object;
50
+ control-flow specials (`s.set_var`, `s.conditional`, `s.for`, `s.foreach`, `s.while`,
51
+ `s.group`, `s.switch`, `s.try_catch`, `s.return`, …) keep their authored signatures. **Every
52
+ statement also carries `description` and `disabled`** — inline on the object-arg factories,
53
+ a trailing options object on the positional specials. `disabled: true` is Xano's
54
+ commented-out state: the step stays in the stack and the engine skips it.
55
+
56
+ **Filter a statement's result as it binds.** Any statement with an `as` also takes
57
+ `asFilters` — the editor's `return as <var> | upper` — applied in order, from the same
58
+ `fl.*` catalog as value filters:
59
+
60
+ ```ts
61
+ s.security.create_uuid({ as: "token", asFilters: [fl.upper()] })
62
+ s.set_var("email", inp("raw"), { asFilters: [fl.trim(), fl.lower()] })
63
+ ```
64
+
65
+ It saves a follow-up `s.set_var` for the common "bind it in a different shape" case. A
66
+ statement that binds nothing does not offer the option.
67
+
68
+ **The chain retypes the value.** `InferResponse` folds each filter's declared result, so a
69
+ filtered binding reports what it actually holds rather than `unknown`:
70
+
71
+ ```ts
72
+ s.db.query({ table: users, as: "rows", asFilters: [fl.count()] }) // rows: number
73
+ s.db.query({ table: users, as: "rows", asFilters: [fl.reverse(), fl.first()] }) // rows: Row
74
+ withFilters(ref("rows"), fl.count()) // number
75
+ ```
76
+
77
+ Filters the engine declares as returning `any` — `get`, `set`, `transform`, `json_decode` —
78
+ fold to `unknown`, since no declaration could name their shape. Note this models a filter's
79
+ OUTPUT, not its input: a filter applied to a value it cannot accept returns `null` at
80
+ runtime rather than erroring, and still types as its declared result.
81
+
82
+ **Fields with a fixed set of values take a bare literal.** Where the engine accepts only
83
+ certain spellings, the field's type is that set, so autocomplete offers them and a typo is a
84
+ compile error rather than a runtime failure after deploy:
85
+
86
+ ```ts
87
+ s.ai.external.mcp.tool.run({ url, tool, connection_type: "stream" }) // ✅ "sse" | "stream"
88
+ s.ai.external.mcp.tool.run({ url, tool, connection_type: "streaming" }) // ❌ compile error, and throws
89
+ s.ai.external.mcp.tool.run({ url, tool, connection_type: inp("mode") }) // ✅ resolved at runtime
90
+ ```
91
+
92
+ `"stream"` and `c.text("stream")` encode identically — use whichever reads better. A value
93
+ the SDK can't evaluate (an `inp`/`ref`, or anything with a filter chain) is never checked,
94
+ so a computed field stays authorable.
95
+
96
+ One field is **resolved at encode time** and so is narrower than the rest: `s.db.query`'s
97
+ `returnType` picks which `context.return` block gets written, which has to happen before
98
+ there is a runtime. It takes the bare literal only — a tagged value is a compile error, and a
99
+ dynamic one (`inp`/`ref`, or any filter chain) throws rather than deferring. A spelling
100
+ outside the set throws too, in place of quietly falling back to `list`.
101
+
102
+ **The db family.** Single-record reads and mutations match one field
103
+ (`{ fieldName, fieldValue }`, defaulting to `id`) — there is no composite `(a, b)` form; for
104
+ a two-column lookup use `s.db.query` with a `where` array. Writes take a partial `row: {…}`,
105
+ and an `s.db.edit` writes **only** the columns you list, leaving every unmentioned column at
106
+ its stored value. A cell takes a tagged value or a **bare JS literal typed against its
107
+ column** — `row: { is_hidden: true, notes: "…" }` is exactly `{ is_hidden: c.bool(true),
108
+ notes: c.text("…") }`. The tag comes from the column, so the literal is checked against it:
109
+ a string on an `f.bool()` column is a compile error, and an `f.enum()` column keeps its
110
+ member union. Only `s.db.query` takes a `where`, and its `where`/`sort`/`paging`/`output`
111
+ are applied **by the engine**, not in your stack.
112
+
113
+ **Bulk writes: `bulk.update` REPLACES the row, `bulk.patch` doesn't.** `s.db.bulk.update`
114
+ writes every column an item omits to its zero value (`""`/`0`/`null`), with an HTTP 200 and
115
+ no error — `{ id: 7, status: "done" }` blanks the rest of row 7. `s.db.bulk.patch` writes
116
+ only the keys each item carries, which is what "update these rows" almost always means.
117
+ `export()` warns when a static `items` array omits columns of the bound table (and
118
+ `--strict` fails on it), but an `items` built from a `ref`/`inp` can't be inspected. Related:
119
+ `s.db.bulk.delete` with no `where` is a truncate, so it refuses to encode unless you say
120
+ `allRows: true`.
121
+
122
+ What each op binds decides your response type: `s.db.get` binds **`null`** on a miss (it does
123
+ not throw — null-check it), `s.db.add`/`edit`/`patch` bind the **full written row** including
124
+ auto-assigned `id`/`created_at`, `s.db.del` binds `null`, and `edit`/`del` **throw**
125
+ `NotFound` (404) when nothing matches. `InferResponse` derives all of that automatically.
126
+
127
+ `s.db.query` mirrors the whole Xano query builder — `returnType`, `bind` joins, computed
128
+ `eval` columns, `aggregate` groups, `distinct`, and the full operator set via
129
+ `cmp(left, op, right)` with `and(...)`/`or(...)` for boolean groups. Signatures are in
130
+ `llms/statements-data.md`; four behaviors are worth knowing here:
131
+
132
+ - **A join condition spells its two sides differently.** The joined table's column takes its
133
+ `as` alias; this query's own columns stay bare:
134
+ `bind: [{ table: users, as: "author", join: "left", where: expr(col("author_id"), "=", col("author.id")) }]`.
135
+ Qualifying your own column by the table's name (`col("posts.author_id")`) resolves only if
136
+ the query also sets `tableAlias` — the alias the qualifier is matched against. Unqualified,
137
+ the engine reads the operand as a text literal and fails at runtime with a parse error
138
+ naming the *other* operand, so `db.query` rejects that spelling at export instead.
139
+
140
+ - **Paging changes the response shape.** Supplying `paging` with metadata on (the default)
141
+ returns a **paging envelope** — `{ items, curPage, nextPage, prevPage, offset, perPage,
142
+ itemsReceived }`, plus totals when `totals: true` — instead of a bare `Row[]`, and
143
+ `InferResponse` reflects that. Pass `metadata: false` to keep the bare array. Read
144
+ `nextPage` (`number | null`) as the typed has-next signal.
145
+ - **Don't author `mixed(...)` conditions.** Xano's editor allows a container whose terms
146
+ don't all join the same way, so pulled workspaces contain it and it round-trips — but the
147
+ stored form doesn't record the intended grouping, and the two places it can appear
148
+ disagree: a branch folds terms strictly left to right (`a OR b AND c` = `(a OR b) AND c`)
149
+ while a `db.query` filter inherits SQL's AND-before-OR precedence (`a OR (b AND c)`). Write
150
+ `and(or(a, b), c)` or `or(a, and(b, c))` — each says exactly one thing in every context.
151
+ - **`ignoreEmpty` DROPS the clause — it does not match zero rows.** `cmp(col("owner"), "in",
152
+ ids, { ignoreEmpty: true })` with an empty `ids` returns the **unfiltered** table, where the
153
+ same clause without the flag matches nothing. Never put it on a filter that scopes rows to a
154
+ permitted set: an empty permission list then returns everything. It is for an optional search
155
+ filter, where a blank box really does mean "don't filter". `export()` warns when the operand
156
+ is empty in the bundle; a runtime-empty list is yours to reason about.
157
+ - **Compose a rule set as siblings, not a folded chain.** `and(...)`/`or(...)` take any number
158
+ of terms and encode flat, so build the array and spread it — `and(...rules)`. Folding one term
159
+ at a time (`rules.reduce((acc, r) => and(acc, r))`) nests a container per rule, and nesting
160
+ costs quadratic bytes: 512 terms are 394 KiB as siblings and 21 MiB folded. Past 128 levels the
161
+ build fails with a message naming the fix. Mixing joins? Group each run: `and(or(...anyOf), ...allOf)`.
162
+ - **An aggregate or `eval` `name` is written bare** (`"status"`) and alias-qualified on emit;
163
+ the engine rejects a bare column in either, and an already-dotted joined column passes
164
+ through. The statement also declares the alias it qualified with, so the qualified name
165
+ resolves — nothing to set by hand.
166
+ - **`eval` is where vector search lives.** An `eval` filter pipeline compiles to SQL, so it
167
+ resolves a different filter registry than `fl.*` (which runs in the request) — including
168
+ the distance filters an `f.vector` column needs. Compute the distance, then sort by the
169
+ alias it grafts onto the row; the ranking happens in the database, over the column's index:
170
+
171
+ ```ts
172
+ s.db.query({
173
+ table: chunk, // index: [{ type: "vector", fields: [{ name: "embedding", op: "vector_cosine_ops" }] }]
174
+ eval: [{ name: "embedding", as: "distance",
175
+ filters: [{ name: "vector_cos_distance", arg: [inp("q")] }] }],
176
+ sort: [{ sortBy: "distance", dir: "asc" }], // nearest first
177
+ paging: { per_page: 10, metadata: false },
178
+ })
179
+ ```
180
+
181
+ Match the filter to the index `op` (`VECTOR_FILTERS`, on `@xanots/sdk/internal`, lists the
182
+ family). The same filter
183
+ works on a `where` operand to cut off *by distance* rather than by row count.
184
+ - **A `where` operand may carry filters — except the request-time timestamp ones.** A `where`
185
+ is compiled into SQL, and most filters have a SQL form there (`trim`, `concat`, `upper`,
186
+ `lower`, and the `epochms_add_day` / `epochms_sub_month` family). The request-time timestamp
187
+ filters do not — `epochms_transform`, `epochms_add_ms`, `epochms_add_secs`, `epochms_date`,
188
+ `epochms_from_format` — and the engine doesn't degrade gracefully: the request dies with a
189
+ bare fatal naming nothing. For a relative cutoff, use the SQL-side spelling or compute it in
190
+ an earlier `s.set_var` and `ref()` that. `export()` warns; a bare `c.now()` is always fine.
191
+ - **A `s.switch` case without `break: true` falls through.** The engine's default is
192
+ fallthrough, so a matched case also runs every *later* case body — and the `default` block
193
+ too. Whatever those bodies write gets written two or three times, at HTTP 200, with `tsc`
194
+ and a plain `export` both clean. Set `break: true` on every case unless you mean the
195
+ cascade; `export()` warns when a break-less case has somewhere to fall into.
196
+
197
+ **Addons** enrich each returned row with related data, attached to the row-returning ops
198
+ (`query`/`get`/`add`/`edit`/`patch`). An addon is a single table-bound db query rather than a
199
+ statement stack: `addon({ table, where, output, cardinality })`, where `where` binds it to
200
+ the parent row and `cardinality` shapes the graft (`"single"` object, the default `"list"`,
201
+ `"count"`, `"exists"`, `"aggregate"`).
202
+
203
+ ```ts
204
+ export const authorAddon = addon({
205
+ name: "author",
206
+ table: userTable,
207
+ where: expr(col("id"), "=", inp("user_id")), // bind to the parent row
208
+ output: ["id", "name"],
209
+ cardinality: "single",
210
+ input: { user_id: input.int({ required: true }) },
211
+ });
212
+
213
+ s.db.query({
214
+ table: post,
215
+ addon: [{ addon: authorAddon, as: "_author", input: { user_id: out("author") } }],
216
+ as: "rows",
217
+ });
218
+ ```
219
+
220
+ Attaching a typed handle merges the graft onto the row shape in `InferResponse` with no cast;
221
+ a **bare-name** reference grafts `unknown`. Author `as` relative to a row (`_author`) — when
222
+ the query returns a paging envelope the `items[]` offset is added for you. If an alias
223
+ **shadows an existing column** the build throws, because the engine would silently overwrite
224
+ that column at runtime (Xano convention: prefix with `_`).
225
+
226
+ **Values** — `c.int/text/bool/decimal/null/obj/array`, `c.now()`, `ref(var)`, `inp(input)`,
227
+ `col(name)`, the context refs `auth(path?)`/`env(name)`/`setting(name)`/`sys.*()`,
228
+ `out(name)` for a parent-row column in an addon input, and `toolset(path)` for the token
229
+ and URL parameters bound while a tool runs under its toolset. `withFilters(value, fl.a(), fl.b())`
230
+ attaches the value pipeline from a typed catalog of filters generated from the engine's own
231
+ sources.
232
+
233
+ - **`c.obj`/`c.array` take plain JSON literals only.** A nested tagged value
234
+ (`inp`/`ref`/`auth`/`c.*`) is a compile error. For a computed object — a response, or an
235
+ `api.request` `params` — use a record of values (`{ count: ref("count") }`). For a dynamic
236
+ object argument use `obj({...})`, which builds a checked expression.
237
+ - **An `obj({...})` member may carry a filter chain.** That matters most for the null-safe
238
+ drill: `db.get` binds `null` on a miss, so `obj({ city: ref("row.address.city", { safe: true }) })`
239
+ is the normal shape — no per-member `s.set_var` to hoist it out. `c.now()`, `env()` and
240
+ `sys.*()` are members too. `{ safe: true }` is for a base that exists and may be null: on a
241
+ base nothing binds it would hide a typo behind a `null`, which is why `export()` warns
242
+ (with a did-you-mean) when a `ref()`'s base segment names no `as` in that stack.
243
+ - **`c.int` takes a string or bigint past `Number.MAX_SAFE_INTEGER`.** The engine stores
244
+ integers as strings and has no 53-bit limit, so `c.int("18446744073709551615")` is exact
245
+ where the number literal for it is already `…616`. A `number` that is not a safe integer
246
+ throws rather than encoding the rounded value.
247
+ - **A bare scalar works in any `fl.*` argument.** `fl.get("a.b", 0)` encodes identically to
248
+ `fl.get(c.text("a.b"), c.int(0))`; strings, numbers and booleans are all wrapped for you.
249
+ Objects and arrays still need `c.obj`/`c.array`.
250
+ - **A typed `fl.*` call is capped at its declared argument count.** Passing more throws,
251
+ in the type and at runtime — an extra argument used to ride into the filter's arg list for
252
+ the engine to ignore or fail on. `filter("name", …)` is the untyped escape. Seven filters the
253
+ catalog under-declares stay variadic (`concat`, `index_by`, `get`, `array_merge`,
254
+ `array_merge_recursive`, `jwe_encode`, `jwe_decode`). The other 95 filters take **no**
255
+ arguments at all — they are emitted `()`, so `fl.abs(x)` is a compile error rather than an
256
+ argument silently shipped to an engine that does not accept one.
257
+ - **`api.request` headers take a `{ "Name": value }` record**, values may be tagged:
258
+ `headers: { "x-api-key": env("KEY") }`. Prefer a header over a `?key=` query param for a
259
+ credential — a URL travels into access logs, proxies and `Referer`. That is not envelope
260
+ safety, though: the `as` envelope's `request` half mirrors `url`, `params` **and**
261
+ `headers`, so never return it raw from a credentialed request; read `response.result`.
262
+ - **Some filters require an argument their own docs call optional.** Filter arguments are
263
+ positional, and a short call is refused by the engine before the filter runs — so
264
+ `fl.csv_encode()` and `fl.number_format()` are compile errors here rather than a failure on
265
+ a deployed endpoint. Which filters those are is probed, not declared: `fl.round()` is also
266
+ documented optional and genuinely works. Pass every argument the signature shows without a
267
+ `?`; the runtime guard names the count if you reach it from JavaScript.
268
+
269
+ ```ts
270
+ withFilters(ref("rows"), fl.csv_encode(",", '"', "\\")), // not fl.csv_encode()
271
+ ```
272
+
273
+ - **`fl.csv_encode` writes no header — `fl.csv_create` is the one that does.** They read as
274
+ interchangeable and are not. `csv_encode` emits each row's values in *that row's* key order
275
+ with no normalization across rows, so rows whose keys differ in order or count misalign
276
+ columns silently; nested cells are JSON-encoded, `false` writes empty, and a piped array of
277
+ scalars collapses to a single line. `csv_create` takes the column names as its **piped**
278
+ value and the data as its `rows` argument.
279
+ - **Only `fl.fsort({ type: "number" })` sorts numerically.** Every other comparator —
280
+ including a spelling the engine does not recognize — sorts as case-insensitive text,
281
+ silently and with no error, so `[2, 10, 1]` comes back `[1, 10, 2]`. A lexicographic sort
282
+ agrees with a numeric one whenever the values share a digit count, so this looks correct
283
+ on small data and goes wrong on real data: a "top N by score/distance/recency" endpoint
284
+ returns the right rows in the wrong order. The union rejects the two plausible wrong
285
+ spellings (`"decimal"`, `"int"`) outright.
286
+
287
+ ```ts
288
+ withFilters(ref("rows"), fl.fsort({ path: "score", type: "number", asc: true })),
289
+ ```
290
+ - **`col()` does not resolve to a stored value inside a `db.edit` `row`.** To
291
+ read-modify-write a column — incrementing a counter — `db.get` the row first and pipe its
292
+ bound value through a filter. `col()` evaluates to `null` there, so `fl.add(1)` computes
293
+ `null + 1` and the engine aborts.
294
+
295
+ ```ts
296
+ s.db.get({ table, fieldValue: inp("id"), as: "current" }),
297
+ s.db.edit({ table, fieldValue: inp("id"), row: { clicks: withFilters(ref("current.clicks"), fl.add(c.int(1))) } }),
298
+ ```
299
+
300
+ That pair is **not atomic** — concurrent writers can lose an increment, and no atomic
301
+ increment statement exists. A genuinely safe counter needs the arithmetic in the database
302
+ via `s.db.direct_query`, which in turn needs the table's *physical* Postgres name; that
303
+ name is assigned at import and is not knowable from a `table()` def, so it has to be
304
+ hardcoded after inspecting the deployed table. A typed path requires an engine change
305
+ ([issue #35](https://github.com/xanots/sdk/issues/35)).
306
+ - **A JavaScript body is written as a function, not a `c.text` string.** The lambda
307
+ statement (`s.lambda`) and eight filters (`fl.map`/`filter`/`some`/`every`/`find`/
308
+ `findIndex`/`reduce`/`lambda`) run JavaScript against a small, closed set of injected
309
+ identifiers — and which ones are in scope depends on the surface. Write the body inline
310
+ and the **surface is implied by where it sits**: the bindings are the function's
311
+ parameters, typed from the position, so your editor supplies them and a wrong name is a
312
+ compile error rather than a wrong value in production.
313
+
314
+ ```ts
315
+ // reduce's accumulator is `$result`. Autocomplete says so; `$acc` does not compile.
316
+ withFilters(ref("prices"), fl.reduce({ initial_value: 0, code: ({ $result, $this }) => $result + $this })),
317
+
318
+ // A map body, typed as a map body — nothing names the surface.
319
+ withFilters(ref("prices"), fl.map(({ $this, $index }) => $this * ($index + 1))),
320
+
321
+ // The statement surface binds ambient state only: `$this` here is a compile error.
322
+ s.lambda({ as: "total", code: ({ $var }) => $var.subtotal * 1.2 }),
323
+ ```
324
+
325
+ The parameters are a fiction — only the **body** is sent, and the engine injects the
326
+ bindings as free identifiers — so destructure them. `(b) => b.$this * 2` would emit
327
+ `return b.$this * 2` with `b` undefined at runtime, and the SDK refuses it.
328
+
329
+ **`fl.transform` is not one of these.** It sits next to them and reads like one, but it
330
+ takes a Xano *expression* — no `return`, and the piped value binds as `$0` (or `$$`),
331
+ not `$this`. A `$this` there resolves to null and the call still returns HTTP 200, so
332
+ the SDK refuses both spellings at author time and points at `$0`.
333
+
334
+ ```ts
335
+ // An expression over the piped value — not a JS body.
336
+ withFilters(ref("prices"), fl.transform("$0 * 1.2")),
337
+
338
+ // Parenthesize a pipe inside an object literal, or the filter argument's comma is read
339
+ // as the key separator and every later key silently vanishes.
340
+ withFilters(ref("items"), fl.transform('{ names: ($0|sort|join:","), n: ($0|count) }')),
341
+ ```
342
+
343
+ For a body built away from its call site, `lam.*` names the surface explicitly:
344
+
345
+ ```ts
346
+ const rate = 0.2;
347
+ // Nothing from the enclosing scope crosses implicitly — declare what the body needs.
348
+ // The capture key must differ from the module binding: the loader renames one of two
349
+ // same-named bindings and the prelude is written under the original name.
350
+ s.lambda({ as: "vat", code: lam.fn(({ $var }, { capturedRate }) => $var.total * capturedRate, { surface: "s.lambda", capture: { capturedRate: rate } }) }),
351
+ ```
352
+
353
+ Omit `surface` and the check is deferred to wherever the body lands, which is the thing
354
+ that knows. `lam.file("./lambdas/total.ts")` reads a default-exported function of the
355
+ same shape from its own type-checked module — the deterministic option under a bundler,
356
+ where a function's own source is whatever the bundler emitted. It needs a filesystem, so
357
+ it ships on the Node entry only: `import { lam } from "@xanots/sdk/node"`, whose `lam`
358
+ carries `fn` and `raw` unchanged. `lam.raw(code)` is the text escape hatch, guarded
359
+ identically, and works on either entry. The full
360
+ binding table per surface is in `llms/lambda.md`.
361
+
362
+ Three things to know, all live-verified against a real engine:
363
+
364
+ - A body that **throws does not fail the request** — the engine returns its diagnostic
365
+ text as the value with HTTP 200, so the failure arrives as bad data rather than an
366
+ error. That is engine behavior and not interceptable from an SDK; validate before
367
+ consuming a lambda result numerically, and prefer an authored body, which cannot fail
368
+ that way for a binding reason.
369
+ - The body is a **function body, not a module**: it must `return`, and a top-level
370
+ `import` is a syntax error. Reach a dependency through the **preloaded globals** —
371
+ `crypto`, `fetch`, `Buffer`, `axios`, `jose`, `_`, `math`, `moment`, `DateTime`,
372
+ `uuid` and friends, which need no specifier. A dynamic `import("…")` or `require("…")`
373
+ with a **literal specifier is not portable**: some instances bundle the body before
374
+ running it and resolve every literal specifier ahead of time, so `await
375
+ import("node:crypto")` comes back as the text `Could not resolve "node:crypto"` with
376
+ HTTP 200; others resolve it at run time and it works
377
+ ([issue #265](https://github.com/xanots/sdk/issues/265)).
378
+ - `console` output goes to the **request log**, not stdout.
379
+
380
+ A plain `c.text(...)` body is still accepted and gets the same build-time check — the
381
+ guard sits at the call site, not inside `lam.*` — so an unknown `$identifier` fails
382
+ whichever way you write it ([issue #221](https://github.com/xanots/sdk/issues/221)).
383
+ - **`c.expression("…")` is carried through verbatim and NOT validated.** XanoTS does not
384
+ parse it or type-check it; nothing inside participates in `InferResponse`, so a var named
385
+ there is invisible to a rename that updates every typed `ref()`. A malformed expression
386
+ fails at runtime; one that is merely wrong (`$var.tota1`) returns a wrong answer. Reach for
387
+ it only for syntax the typed surfaces can't express — `~` concatenation, inline arithmetic,
388
+ conditionals — and note it is **not** the `expr()` condition builder.
389
+ (`c.expressionLegacy` exists only so `codegen` can return an older stored form.)
390
+
391
+ **System / request variables (`sys.*`).** Xano's built-in request context reads as
392
+ `$env.$remote_ip` in XanoScript — note the **second `$`**: these are settings with a
393
+ `$`-prefixed name, the same tag `env()` emits. That prefix is the footgun, because
394
+ `env("remote_ip")` reads a workspace env var literally named `remote_ip` (almost always
395
+ unset → null) rather than the caller's IP. `sys.*` spells the prefixed names for you:
396
+
397
+ | accessor | var | | accessor | var |
398
+ |---|---|---|---|---|
399
+ | `sys.remoteIp()` | `$remote_ip` | | `sys.datasource()` | `$datasource` |
400
+ | `sys.requestMethod()` | `$request_method` | | `sys.branch()` | `$branch` |
401
+ | `sys.requestUri()` | `$request_uri` | | `sys.tenant()` | `$tenant` |
402
+ | `sys.requestQueryString()` | `$request_querystring` | | `sys.release()` | `$release` |
403
+ | `sys.httpHeaders()` | `$http_headers` | | `sys.platform()` | `$platform` |
404
+ | `sys.requestAuthToken()` | `$request_auth_token` | | `sys.isDebugger()` | `$debugger` |
405
+ | `sys.apiBaseUrl()` | `$api_baseurl` | | | |
406
+
407
+ `setting("$<name>")` covers anything `sys` doesn't. The one that matters most in practice is
408
+ `sys.remoteIp()`, the rate-limit key for public endpoints.
409
+
410
+ **Inputs** — `input.*` mirrors `f.*` exactly: every engine-legal field type is a valid
411
+ function/query input, with `input.object(children)` and `input.list(element)` for structured
412
+ shapes. Comparisons use `= != > < >= <=`.
413
+
414
+ **Validate input at the boundary.** Field types don't enforce arbitrary
415
+ rules, and `s.precondition` raises a **status-bearing** error a client can detect via
416
+ `res.ok` — unlike `s.throw`, which returns 200 with an error body. `error_type` picks the
417
+ status: `badrequest`/`inputerror` → 400, `unauthorized` → 401, `accessdenied` → 403,
418
+ `notfound` → 404, `toomanyrequests` → 429, `standard` (the default) → 500.
419
+
420
+ ```ts
421
+ s.precondition({
422
+ // `fl.regex_test` is PATTERN-piped: the piped value is the regex and the arg is the
423
+ // text tested — the reverse of `istarts_with`. Build the pattern with `c.regex(...)`,
424
+ // which delimiter-wraps it (a bare `c.text("^…")` is an invalid PCRE matching nothing).
425
+ expr: expr(withFilters(c.regex("^https?://", "i"), fl.regex_test(inp("url"))), "=", c.bool(true)),
426
+ error_type: "badrequest",
427
+ error: c.text("url must be an http(s) URL"),
428
+ })
429
+ ```
430
+
431
+ **Normalize on the input, not in the stack.** `methods` run at bind, before your stack, so
432
+ `input.email({ methods: ["lower"] })` makes `inp("email")` read already-normalized. Don't
433
+ reroll `trim`/`lower`/`upper` into a var.
434
+
435
+ **Email/password auth.** The trap: `input.password()` **hashes on bind**, so a password
436
+ typed that way is already a hash before your stack runs, and `check_password` then compares
437
+ hash against hash — login always fails. Take the password as **plain text** and let the
438
+ `f.password` *column* hash it on write; `check_password` compares the plaintext submission
439
+ against the stored hash.
440
+
441
+ ```ts
442
+ // Signup — plaintext in; the f.password COLUMN hashes on write.
443
+ query({ name: "signup", verb: "POST", apiGroup: authApi,
444
+ input: { email: input.email({ required: true }), name: input.text(),
445
+ password: input.text({ required: true, methods: ["min:6"] }) }, // NOT input.password()
446
+ stack: [
447
+ s.db.add({ table: usersTbl,
448
+ row: { email: inp("email"), name: inp("name"), password: inp("password") }, as: "user" }),
449
+ s.security.create_auth_token({ table: usersTbl, id: ref("user.id"), as: "token" }),
450
+ ],
451
+ response: ref("token") });
452
+
453
+ // Login — plaintext compared against the stored hash.
454
+ query({ name: "login", verb: "POST", apiGroup: authApi,
455
+ input: { email: input.email({ required: true }),
456
+ password: input.text({ required: true }) }, // NOT input.password()
457
+ stack: [
458
+ s.db.get({ table: usersTbl, fieldName: "email", fieldValue: inp("email"),
459
+ output: ["id", "email", "password"], as: "user" }),
460
+ s.precondition({ expr: expr(ref("user"), "!=", c.null()),
461
+ error_type: "accessdenied", error: c.text("Invalid email or password.") }),
462
+ s.security.check_password({ text_password: inp("password"), // plaintext
463
+ hash_password: ref("user.password"), as: "ok" }),
464
+ s.precondition({ expr: expr(ref("ok"), "=", c.bool(true)),
465
+ error_type: "accessdenied", error: c.text("Invalid email or password.") }),
466
+ s.security.create_auth_token({ table: usersTbl, id: ref("user.id"), as: "token" }),
467
+ ],
468
+ response: ref("token") });
469
+ ```
470
+
471
+ Reach for `input.password()` only when you specifically want its bind-time hash **and** are
472
+ not also feeding it to `check_password`.
473
+
474
+ ## Middleware, request history & env vars
475
+
476
+ A `middleware({...})` is reusable logic (`input`/`stack`/`response` + `resultStrategy:
477
+ "merge"|"replace"` + `exceptionPolicy`). To run one, *attach* it with a host's
478
+ `middleware: { pre, post }` field on `query`/`apiGroup`/`defineFunction`/`task`/`tool`
479
+ (not triggers). Prefer a def handle over a bare name, the same rule as `auth`/`apiGroup`
480
+ references; `{ middleware: mw, active: false }` keeps an entry but disables it.
481
+
482
+ ```ts
483
+ query({
484
+ name: "get_user", verb: "GET", apiGroup: blog,
485
+ middleware: { pre: [rateLimit], post: [audit] },
486
+ stack: [/* ... */], response: ref("user"),
487
+ });
488
+ ```
489
+
490
+ - **`exceptionPolicy` decides whether a guard is a guard.** `"silent"` **is the default**
491
+ and swallows the throw, so a rate limit or auth check authored without an explicit policy
492
+ is **not enforced**. `"rethrow"` aborts the request and surfaces the authored
493
+ `error`/status (a tripped `s.redis.ratelimit` → 429) while still running `post`;
494
+ `"critical"` is the same but skips the `post` chain. That is the only difference.
495
+ - **Inheritance is override, not merge.** Providing a phase overrides it; omitting a phase
496
+ inherits the parent tier's chain, resolved at request time **Query → API Group →
497
+ Workspace**. `pre: middleware.clear()` overrides a phase with nothing.
498
+ - **Setting `workspaceConfig.middleware` at all emits the whole map.** Any host/phase you
499
+ don't list is emitted empty, which **clears** that tier on deploy. Omit the field entirely
500
+ to leave the workspace's existing middleware untouched. The same wholesale rule applies to
501
+ `datasources`.
502
+ - **`auth()` is `null` on a public host**, and a `pre` middleware runs after auth resolution.
503
+ A rate limit keyed by `auth("id")` on a public endpoint collapses every caller into one
504
+ bucket, silently. `export()` warns on direct attachment of an `auth()`-keyed middleware to
505
+ a host where `auth()` may be null.
506
+ - A `resultStrategy: "replace"` middleware attached `post` rewrites the response at runtime,
507
+ which `InferResponse` can't see — declare `responseShape` on the endpoint.
508
+ - `workspaceConfig` also carries `realtime`, `documentation`, and `swagger`, which are
509
+ server-shaped and carried verbatim rather than authored. `realtime` there is the **legacy**
510
+ workspace-level block, not the realtime primitives you author.
511
+
512
+ **The canonical rate-limit middleware.** Build the per-user key with the filter chain
513
+ (`"prefix" + auth("id")` doesn't exist):
514
+
515
+ ```ts
516
+ const writeRl = middleware({
517
+ name: "write_rl",
518
+ exceptionPolicy: "rethrow", // a tripped limit must abort (silent would let it through)
519
+ stack: [
520
+ s.redis.ratelimit({
521
+ key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), // "rl:write:<id>"
522
+ max: c.int(10), ttl: c.int(30), error: c.text("Too fast."),
523
+ }),
524
+ ],
525
+ });
526
+
527
+ query({ name: "create_post", verb: "POST", apiGroup: blog, auth: users, // authed ⇒ per-user
528
+ middleware: { pre: [writeRl] }, stack: [/* ... */], response: ref("post") });
529
+ ```
530
+
531
+ On a **public** endpoint key off the client IP instead — `sys.remoteIp()` — since
532
+ `auth("id")` is null there. And note the **shared-bucket rule**: co-attaching one middleware
533
+ object to N hosts means all N share the same key and therefore one counter, so `max: 10` is
534
+ a global budget across them. Vary the key (fold the host name into the prefix) for an
535
+ independent limit per host.
536
+
537
+ **Reading the request body in a `pre` middleware.** It does receive the host's inputs, via
538
+ `s.util.get_all_input({ as: "payload" })` — but the result is **wrapped as `{ type, vars }`**,
539
+ so a body field lives at `ref("payload.vars.<field>")`. The un-nested path is the usual cause
540
+ of an `Unable to locate var` 500.
541
+
542
+ **Request history** — the per-object execution trace behind Xano's debugger, authored as a
543
+ single scalar `history` field: `false` off, `true` on at the default depth, a **number** =
544
+ capture depth (statement executions recorded per record, *not* records retained), `"all"` =
545
+ unlimited. **Omitting it inherits**; any value stops inheriting. Inheritance resolves
546
+ **object → container → workspace** (a query from its API group, a tool from its
547
+ toolset/agent, everything else straight from the workspace). Per-kind defaults when
548
+ inheriting: query / task / tool capture **on**; function / trigger / middleware **off**.
549
+ `workspaceConfig.history` is wholesale in the same way the middleware map is.
550
+
551
+ ```ts
552
+ query({ name: "get_user", verb: "GET", history: 100 }); // capture, depth cap 100
553
+ apiGroup({ name: "blog", history: false }); // default for its queries
554
+ workspaceConfig({ history: { query: 100, trigger: "all" } }); // name inherited from workspace("…")
555
+ ```
556
+
557
+ **Workspace environment variables** — author them as a name→value map on the workspace
558
+ object; read them at request time with `env("NAME")`:
559
+
560
+ ```ts
561
+ workspaceConfig({
562
+ name: "my-app",
563
+ env: {
564
+ STRIPE_KEY: process.env.STRIPE_KEY!, // sourced from the deploy environment
565
+ APP_BASE_URL: "https://my-app.example.com", // a plain config value
566
+ },
567
+ });
568
+ ```
569
+
570
+ **Values are secrets.** Prefer sourcing them from the deploy environment over committing
571
+ literals, and don't commit a compiled bundle holding real ones. Deploying sets the vars you
572
+ declare; omit the field to leave the workspace's existing env untouched.
573
+
574
+
575
+ ## Seed data
576
+
577
+ Give a table `seed` rows and they ship into the database on deploy — so a fresh
578
+ environment comes up with lookup tables, demo content, or fixtures already in place,
579
+ not empty:
580
+
581
+ ```ts
582
+ const product = table({
583
+ name: "product",
584
+ schema: {
585
+ sku: f.text({ required: true }),
586
+ name: f.text({ required: true }),
587
+ price: f.decimal(),
588
+ tags: f.text({ array: true }),
589
+ },
590
+ // Rows are validated against the column types before deploy. A column without
591
+ // `required: true` may be omitted (the engine applies its default). Omit `id` and
592
+ // rows are keyed for you — 1..N for an int PK, a stable uuid for a uuid PK (or
593
+ // set `id` on every row); a bad value
594
+ // or unknown column is a loud error, never a silent drop.
595
+ seed: [
596
+ { sku: "SKU-001", name: "Aeron Chair", price: 1395, tags: ["furniture", "ergonomic"] },
597
+ { sku: "SKU-002", name: "Standing Desk", price: 599, tags: ["furniture"] },
598
+ ],
599
+ });
600
+ ```
601
+
602
+ Pinning an `id` this way is a `seed` property, not a general bulk-insert one: the runtime
603
+ statement `s.db.bulk.add` **drops `id` from every row** unless you pass `allowIdField: true`,
604
+ assigning the next sequence value instead.
605
+
606
+ Its sibling `s.db.bulk.delete` has the mirror-image rule: a filter that constrains nothing
607
+ matches every row, so a `where`-less delete is a truncate. It **throws** unless you say so
608
+ with `allRows: true`, which wipes the table and returns the deleted count — the wipe can no
609
+ longer be reached by forgetting an argument. Reach for `s.db.truncate({ table, reset: true })`
610
+ when the id sequence should restart too.
611
+
612
+ Deploy is a full replace, so re-deploying re-seeds cleanly — no duplicate rows. Seed
613
+ data travels only in the deploy package (resolved at deploy time); it never enters the
614
+ compiled workspace bundle.
615
+
616
+ For data in a file, use `seedFile`:
617
+
618
+ ```ts
619
+ seed: seedFile("./products.seed.json", import.meta.url),
620
+ ```
621
+
622
+ The path resolves against the file that declares the table, and it is read with `node:fs`
623
+ at deploy time. Note the tradeoff: inline rows are typed against the table schema at compile
624
+ time, while a `seedFile`/thunk seed is opaque to the typechecker — `xanots export`/`deploy`
625
+ validates it instead, naming the row index, the offending column and the table's known
626
+ columns. A thunk (`seed: () => import("./products.seed.json")`) also works and is
627
+ the right shape for *computed* seeds — but be aware it does **not** keep seed values out of
628
+ a frontend build: the `import()` lives in your module, so a bundler emits the JSON as a
629
+ served chunk, and any frontend that imports a def whose module graph reaches that table
630
+ ships the seed to the browser. `seedFile` stores a path string, which a bundler has nothing
631
+ to follow.
632
+
633
+ Either way, keep secrets out of `seed` — it is throwaway fixture data for disposable
634
+ environments. As a backstop, `xanots deploy <entry> --static <dir>` and `xanots release <entry> --static <dir>`
635
+ refuse to publish a frontend build containing seed values from columns your schema marks
636
+ `access: "internal"` or `sensitive` (pass `--allow-seed-in-static` if the data is
637
+ deliberately public).
638
+
639
+ Typing is unaffected by the form you choose — the table's row type and column names stay
640
+ inferred.
641
+
642
+ ---