@xanots/sdk 0.0.3 → 0.0.5
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 +79 -0
- package/README.md +38 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/agent-file-refresh-IJ7GN2FJ.js +17 -0
- package/dist/bin.js +12 -153
- package/dist/{capture-HUV5BNTC.js → capture-7PO6SGB4.js} +2 -2
- package/dist/{chunk-CMZLPTGW.js → chunk-7SISR3CS.js} +2 -2
- package/dist/{chunk-XQ22GLYS.js → chunk-AAFBSOSD.js} +6 -6
- package/dist/{chunk-BT2CSEC5.js → chunk-APOR6TCV.js} +4 -4
- package/dist/{chunk-55VHDNW5.js → chunk-BPEEGJJA.js} +108 -3
- package/dist/{chunk-U3G2UW65.js → chunk-CYJ7R3AD.js} +3 -3
- package/dist/{agent-file-refresh-6M5M7T24.js → chunk-D6OGU2AI.js} +5 -6
- package/dist/chunk-ERQZFWIW.js +22 -0
- package/dist/{chunk-DYCLVQXW.js → chunk-F5T45NAM.js} +31 -2
- package/dist/{chunk-TQUO2OXY.js → chunk-FN4OQT2J.js} +4 -4
- package/dist/{chunk-6USV65XA.js → chunk-IVP6TS26.js} +2 -2
- package/dist/{chunk-HQ2CBRVI.js → chunk-KXCPZUUM.js} +10 -8
- package/dist/{chunk-7REDODS2.js → chunk-LLWK6H77.js} +20 -2
- package/dist/{chunk-HJPTWBLH.js → chunk-MFIHIS6B.js} +2 -2
- package/dist/{chunk-3NFUWXOC.js → chunk-MS3BRZAZ.js} +146 -45
- package/dist/{chunk-2V4YE6QC.js → chunk-OARSPAIE.js} +112 -21
- package/dist/{chunk-7DKX2SPN.js → chunk-OR43PDCW.js} +4 -4
- package/dist/chunk-PGBK64U5.js +59 -0
- package/dist/{chunk-F6JCFRKO.js → chunk-PXXLBXOP.js} +2 -2
- package/dist/{chunk-W2G2WPTB.js → chunk-QA5ICJ4M.js} +3 -3
- package/dist/{chunk-YZ4GU6F5.js → chunk-QUUB7HYK.js} +5 -838
- package/dist/{chunk-C56BC2FY.js → chunk-SG4UBJ47.js} +2 -2
- package/dist/{chunk-76QBEIGO.js → chunk-T4XPCJRF.js} +3 -2
- package/dist/{chunk-VTIL47DT.js → chunk-W5NOKYEG.js} +6 -2
- package/dist/chunk-WGDAOOXG.js +845 -0
- package/dist/{chunk-DGSF2Q5H.js → chunk-WJYB7DSI.js} +2 -2
- package/dist/{chunk-4YMD2OOZ.js → chunk-XHEXOES3.js} +1 -1
- package/dist/{chunk-4HT3BNZ7.js → chunk-YUBJLB6G.js} +10 -2
- package/dist/{chunk-5L4X5LS6.js → chunk-YUPQOLFX.js} +77 -2
- package/dist/chunk-ZGA5MUNC.js +173 -0
- package/dist/{chunk-P3TTMWUP.js → chunk-ZOYMZZ3S.js} +8 -1
- package/dist/chunk-ZSYZTGJH.js +81 -0
- package/dist/cli.d.ts +14 -1
- package/dist/cli.js +8 -8
- package/dist/codegen-command-BLOS3GR3.js +43 -0
- package/dist/{completion-BKAFCZBE.js → completion-GHP5RRLQ.js} +2 -2
- package/dist/{deploy-command-4R6BYC6G.js → deploy-command-XOH76USO.js} +27 -27
- package/dist/{env-target-XWS2ZZ2Y.js → env-target-POGJMD6Q.js} +7 -7
- package/dist/{ephemeral-command-JL4TPIRQ.js → ephemeral-command-46SL27GJ.js} +25 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.js +13 -7
- package/dist/init-command-NCRPVFGE.js +30 -0
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +50 -5
- package/dist/{io-AMIKRLPC.js → io-7VIA5SON.js} +3 -3
- package/dist/{live-diff-RXSJCVJ7.js → live-diff-FP4SFNT4.js} +2 -2
- package/dist/{lock-3CVKALKT.js → lock-KXOJIGCG.js} +2 -2
- package/dist/{lock-commands-XOYS75YQ.js → lock-commands-6UONZW26.js} +9 -9
- package/dist/{login-command-ACJF6KWQ.js → login-command-SG7IWTHW.js} +140 -37
- package/dist/{logout-command-MX3MJS5U.js → logout-command-J2AG5NKC.js} +3 -3
- package/dist/{loop-SAWAOUFO.js → loop-7SAIGRCZ.js} +3 -3
- package/dist/{marketplace-command-UVV3XAOL.js → marketplace-command-NKTQ3VPS.js} +11 -25
- package/dist/meta-client-OW5WKWW7.js +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.js +18 -12
- package/dist/onboard-command-EHOHKQQU.js +36 -0
- package/dist/{profile-command-SWJ3SPKR.js → profile-command-ZPC2DPFV.js} +6 -6
- package/dist/{release-command-HZUY2XZX.js → release-command-FKQ6E3TD.js} +25 -25
- package/dist/{sandbox-details-command-HJE5SPVG.js → sandbox-details-command-DMY2KGA2.js} +5 -5
- package/dist/{sandbox-export-command-QCJY4GMV.js → sandbox-export-command-R6QMEK3D.js} +8 -8
- package/dist/scaffold.js +4 -2
- package/dist/{store-g45zwB33.d.ts → store-BJONDJoZ.d.ts} +171 -2
- package/dist/{test-command-72Y5S22H.js → test-command-56IAEYHX.js} +11 -11
- package/dist/upgrade-command-M2DZ3ZPD.js +177 -0
- package/dist/{validate-command-KDGH537H.js → validate-command-CIBQJND7.js} +12 -12
- package/dist/{workspace-command-ICSI6PKO.js → workspace-command-HZS43PHJ.js} +28 -27
- package/dist/workspace-export-AJMGN3CQ.js +1 -1
- package/guides/README.md +30 -0
- package/guides/authoring.md +642 -0
- package/guides/cli.md +192 -0
- package/guides/codegen.md +83 -0
- package/guides/coverage.md +67 -0
- package/guides/deploying.md +358 -0
- package/guides/environment.md +132 -0
- package/guides/object-kinds.md +376 -0
- package/guides/project-structure.md +43 -0
- package/guides/scaffold.md +198 -0
- package/guides/typed-frontend.md +201 -0
- package/llms/kinds-knowledge.md +20 -0
- package/llms/object-kinds.md +1 -0
- package/llms-full.txt +25 -2
- package/llms.txt +3 -2
- package/manifest.json +31 -4
- package/package.json +5 -2
- package/dist/chunk-WUSKBXXD.js +0 -25
- package/dist/chunk-ZO3HJOCJ.js +0 -29
- package/dist/codegen-command-FIADGXRN.js +0 -42
- 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
|
+
---
|