@xanots/sdk 0.0.13 → 0.0.14

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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/{agent-file-refresh-GQWAAOBV.js → agent-file-refresh-2LZ2YRFZ.js} +3 -3
  4. package/dist/bin.js +2 -2
  5. package/dist/{chunk-2AY3PKF4.js → chunk-CII2TOE4.js} +2 -2
  6. package/dist/{chunk-2VTJSI6X.js → chunk-F3MRG7NI.js} +2 -2
  7. package/dist/{chunk-RLI6XD4O.js → chunk-LISUHLWI.js} +9 -9
  8. package/dist/{chunk-S3DOJOW4.js → chunk-ORZINVGQ.js} +4 -4
  9. package/dist/{chunk-SS2V2QOG.js → chunk-UQMMFW32.js} +4 -4
  10. package/dist/{chunk-OONT4ZL4.js → chunk-UXPDQFG2.js} +2 -2
  11. package/dist/{chunk-VK26K7AY.js → chunk-XBIFQWZM.js} +2 -2
  12. package/dist/cli.js +1 -1
  13. package/dist/{codegen-command-ZDHMGFWZ.js → codegen-command-5A6FVKUA.js} +5 -5
  14. package/dist/{deploy-command-CGVKRWUE.js → deploy-command-VBB7JYXH.js} +3 -3
  15. package/dist/{init-command-DNDONP3O.js → init-command-SIJUH2DD.js} +4 -4
  16. package/dist/internal.js +357 -259
  17. package/dist/{lock-commands-6U7UIJGR.js → lock-commands-4TX3BONI.js} +2 -2
  18. package/dist/node.js +1 -1
  19. package/dist/{preflight-command-UYE7SUQV.js → preflight-command-MTYGPNVT.js} +3 -3
  20. package/dist/{release-command-CMMYT6XK.js → release-command-NXCPQDFF.js} +5 -5
  21. package/dist/{upgrade-command-A75DOIUH.js → upgrade-command-GSH3M3N6.js} +5 -5
  22. package/dist/{workspace-command-YELP47SJ.js → workspace-command-XUOPLVQ4.js} +5 -5
  23. package/guides/README.md +1 -1
  24. package/guides/coverage.md +3 -2
  25. package/llms/client.md +44 -0
  26. package/llms/errors.md +33 -0
  27. package/llms/fields.md +26 -1
  28. package/llms/filters.md +4 -8
  29. package/llms/kinds-agent-mcp.md +1 -0
  30. package/llms/kinds-core.md +16 -2
  31. package/llms/kinds-knowledge.md +1 -1
  32. package/llms/kinds-realtime.md +1 -1
  33. package/llms/lambda.md +1 -1
  34. package/llms/legacy.md +1 -1
  35. package/llms/object-kinds.md +2 -2
  36. package/llms/statements-calls.md +25 -4
  37. package/llms/statements-catalog.md +1 -1
  38. package/llms/statements-runtime.md +20 -5
  39. package/llms/tests.md +2 -2
  40. package/llms/triggers.md +14 -2
  41. package/llms/values.md +2 -2
  42. package/llms-full.txt +359 -267
  43. package/llms.txt +163 -234
  44. package/manifest.json +4 -4
  45. package/package.json +1 -1
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # xanots v0.0.13
1
+ # xanots v0.0.14
2
2
 
3
3
  > TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
4
4
 
@@ -15,11 +15,11 @@ TAGGED value — `ref("x")` a stack variable, `inp("x")` an input, `auth("id")`
15
15
  caller, `c.*` a constant — resolved at request time; JS operators over them do not
16
16
  compute (see Gotchas). Requests share no memory — state persists in tables or redis.
17
17
 
18
- Coverage: object kinds 25/31, statement surfaces 214/214, filters 226 (226 typed).
18
+ Authorable: statement surfaces 214/214, filters 226 (226 typed).
19
19
  Not authorable here: branch, market_item, realtime_channel, run.job, run.service, tablemap — these cannot be authored and do not survive a pull; reasons in `coverage.objectKinds.unmodeled`.
20
20
 
21
21
  This file is the whole always-read surface: the mental model, the deploy contract,
22
- every gotcha, and control flow. Per-surface detail lives in the topic files listed
22
+ every cross-cutting gotcha, and control flow. Per-surface detail lives in the topic files listed
23
23
  below — open the one whose condition matches the task, skip the rest. For
24
24
  exhaustive per-entry detail in NEITHER — a statement's field schema with engine
25
25
  defaults, a filter's full argument list, the `storedName` mapping — do a TARGETED
@@ -37,23 +37,25 @@ Paths are relative to this file (`node_modules/@xanots/sdk/` once installed), so
37
37
  plain file read resolves them at the version you have.
38
38
  `llms-full.txt` is everything concatenated — one fetch for a reader that cannot open files.
39
39
 
40
- - [Object kinds](llms/object-kinds.md): Read when deciding WHAT to build — every authorable primitive (tables, endpoints, functions, tasks, agents, MCP, realtime, microservices, …), what each is, and which factory + register method build it. Read it too when a `register*` call will not typecheck because the array was built by `.flatMap()`/`.concat()` across modules.
40
+ - [Object kinds](llms/object-kinds.md): Read when deciding WHAT to build — every authorable primitive, what each is, and which factory + register method builds it. Also when `register*` will not typecheck an array built by `.flatMap()`/`.concat()`.
41
41
  - [Core def shapes](llms/kinds-core.md): Read when authoring a function, query, api group, task, workflow test, middleware, or tool — and for the `response` and `expr` shapes every one of them uses.
42
- - [Saved unit tests, assertions, and mocks](llms/tests.md): Read when authoring a `workflowTest()` stack, when a query/function/middleware carries `tests`, when a statement should mock a value, or when running a deployed environment's tests.
42
+ - [Consuming defs from a client](llms/client.md): Read when a frontend imports a def (`getPath()`/`InferResponse`) or calls the deployed backend what the import costs, `window.XANO_HOST`, local dev, file URLs, and a Node spot-check.
43
+ - [Error index](llms/errors.md): Read when a request, test, build, or pull fails with a message you did not write — the string, its cause, and where the fix is.
44
+ - [Saved unit tests, assertions, and mocks](llms/tests.md): Read when authoring a `workflowTest()` stack, when a query/function/middleware carries `tests`, when a statement needs a `mock`, or when running a deployed environment's tests.
43
45
  - [Agent and MCP def shapes](llms/kinds-agent-mcp.md): Read when the workspace defines an `agent()` or an `mcpServer()`.
44
- - [Knowledge def shape](llms/kinds-knowledge.md): Read when the workspace defines a `knowledge()` item the markdown its AI agents read.
46
+ - [Knowledge def shape](llms/kinds-knowledge.md): Read when the workspace defines a `knowledge()` item for its AI agents.
45
47
  - [Realtime def shapes](llms/kinds-realtime.md): Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.
46
48
  - [Triggers](llms/triggers.md): Read when authoring any trigger. A trigger's `stack` is a callback rather than the plain array every other kind takes, so the shape does not carry over.
47
49
  - [Array and database statements](llms/statements-data.md): Read when the stack reads or writes rows (`s.db.*`), or transforms an array in place (`s.array.map`, `s.array.union`).
48
- - [Statement runtime behavior](llms/statements-runtime.md): Read when you need to know what a statement's `as:` output actually holds, or why a bound variable is not the shape you expected.
50
+ - [Statement runtime behavior](llms/statements-runtime.md): Read when you need to know what a statement's `as:` output holds, or why a bound variable is not the shape expected.
49
51
  - [Auth, cross-object calls, and microservices](llms/statements-calls.md): Read when the stack authenticates (the signup/login/token recipe is here), calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), sends email (`s.util.send_email`), or reaches a microservice.
50
52
  - [Value catalog](llms/values.md): Read when you need a literal, a reference, or a tag you have not used before — `c.*`, `ref`, `inp`, `auth`, `col`, and what each one encodes to.
51
- - [Column and input types](llms/fields.md): Read when declaring a table column (`f.*`) or a function/query input (`input.*`) — a type's options and accessor methods, and the `s.precondition` error/status contract that rides the same catalog.
53
+ - [Column and input types](llms/fields.md): Read when declaring a table column (`f.*`) or a function/query input (`input.*`) — a type's options and methods, and the `s.precondition` error/status contract. Also `table({ seed })` rows and `use_xdo`.
52
54
  - [Filter catalog](llms/filters.md): Read when piping a value through `fl.*` — the full catalog with each filter's argument types.
53
- - [Lambda bodies (JavaScript)](llms/lambda.md): Read when writing a JavaScript body, or weighing whether to reach for one at all — `lam.fn`, `fl.lambda`, `fl.reduce`, `s.lambda`. Lambda workers are a bounded shared resource, and each surface binds a different set of identifiers.
55
+ - [Lambda bodies (JavaScript)](llms/lambda.md): Read when writing a JavaScript body, or weighing whether to reach for one at all — `lam.fn`, `fl.lambda`, `fl.reduce`, `s.lambda`; each surface binds a different set of identifiers.
54
56
  - [Lock file](llms/lock.md): Read when a `xano.lock` exists or should — renaming/pruning/importing identities, seeding the lock programmatically, or asking which commands write it.
55
- - [Legacy paradigms and retired statements](llms/legacy.md): Read when the code was PULLED from an existing Xano instance rather than authored here — how a codegen'd tree reads and the three shapes in it that must not be "fixed", plus a `raw({ name: "mvp:…" })` statement, a `realtimeTrigger()`, or any name the catalogs do not list.
56
- - [Statement catalog](llms/statements-catalog.md): Read for the field signature of a specific statement — every surface, grouped by `s.*` namespace. Look here after the control-flow core in the router does not cover what you need.
57
+ - [Legacy paradigms and retired statements](llms/legacy.md): Read when the code was PULLED from an existing Xano instance — how a codegen'd tree reads, the shapes in it that must not be "fixed", and any `raw({ name: "mvp:…" })`, `realtimeTrigger()`, or name the catalogs do not list.
58
+ - [Statement catalog](llms/statements-catalog.md): Read for the field signature of a specific statement — every surface, grouped by `s.*` namespace.
57
59
 
58
60
  ## Quickstart
59
61
 
@@ -125,6 +127,8 @@ operand, a `ref()` nothing binds). Each has a legitimate use, so each only warns
125
127
  but nothing fails on a message no one reads, so in CI and in unattended agent builds
126
128
  pass `--strict` (`emitBundle(app, { strict: true })` / `app.export({ strict: true })`):
127
129
  every warning becomes a hard failure. Same bundle bytes either way.
130
+ Before calling the work done: `npm run typecheck`, `xanots export <entry> --strict`,
131
+ `xanots deploy <entry>`, then `xanots test run-all` against what deployed.
128
132
 
129
133
  Identity: object guids derive from `(type, name)` — a query's from `(api group,
130
134
  verb, name)` — so renames change identity.
@@ -170,90 +174,89 @@ production workspace, but confirm with the user before the first run.
170
174
 
171
175
  **Frontend wiring.** `--static <dir>` injects the DEPLOYED env's backend URL as
172
176
  `window.XANO_HOST` into EVERY html document in the build, before the app bundle runs,
173
- so the frontend needs no rebuild to target an env. Every document, not just the root:
174
- a prerendered build serves a different one per route, and a route without the global
175
- renders fine while every call goes to the wrong origin. Read it at runtime with a build-time
176
- fallback:
177
- const HOST = (typeof window !== 'undefined' && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
178
- In LOCAL DEV there is no injected global, so the fallback is what answers: set
179
- `VITE_XANO_HOST` in a `.env.local` beside `.env.example` at the PROJECT ROOT. The
180
- scaffold's vite config sets `envDir` there (its `root` is `frontend/`, and Vite
181
- resolves `.env` files against `root`) — without it the var reads as undefined, the
182
- host falls back to '', and every call 404s off the dev server.
183
- ⚠ It is INJECTED in bracket form — `window["XANO_HOST"]="…"` — so verifying a deploy
184
- by grepping `window.XANO_HOST` matches nothing and reads as a failed inject. Grep the
185
- bare `XANO_HOST` token.
186
- ⚠ `xanots preflight` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
187
- `XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) from the environment.
188
- **Displaying a stored file.** A file column comes back as `{ path, name, type, size,
189
- meta, access, url }`. ⚠ Do NOT use its `url`: on a tenant-scoped environment that field
190
- addresses the instance host WITHOUT the `/tenant/<name>` segment and 404s, silently —
191
- as a broken `<img>`, with every API assertion still passing. Build the URL from `path`
192
- instead: `fileUrl(row.avatar, HOST)` (exported from `@xanots/sdk`) returns `null` for
193
- an absent file and is correct on an ephemeral and an instance workspace alike.
194
- ⚠ A static host serves these files verbatim, so everything injected is PUBLIC — base
177
+ so the frontend needs no rebuild to target an env. How to read it, local dev, verifying
178
+ the inject, and displaying a stored file: `llms/client.md`.
179
+ A static host serves its files verbatim, so everything injected is PUBLIC base
195
180
  URLs and publishable keys only, never secrets. Secrets go in backend env, read
196
181
  server-side via `env(name)`.
182
+ ⚠ `xanots preflight` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /
183
+ `XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) from the environment.
197
184
 
198
185
  **Full CLI surface:** `xanots <command> --help` lists every command, flag, and
199
186
  default; the shipped `manifest.json` carries the same in its `cli` array. This doc
200
187
  does not duplicate it — it covers what you must know to AUTHOR a workspace.
201
- **Recommended style:** reach statements through the `s` namespace
202
- (`s.db.add`, `s.math.add`, …) one discoverable, tab-completable surface. The
203
- flat factory aliases (`dbAdd`, `dbQuery`, `setVar`, `mathAdd`, …) are exported
204
- and identical in output; prefer `s.*` in new code so examples stay consistent.
188
+ **Style:** reach statements through the `s` namespace (`s.db.add`, `s.math.add`, …).
189
+ The flat aliases (`dbAdd`, `setVar`, …) are identical in output; prefer `s.*`.
205
190
 
206
191
  ## Gotchas
207
192
 
208
193
  Non-obvious authoring rules:
209
194
 
195
+ ### Values and references
196
+
210
197
  - **No callback builder.** Flat def-objects + `register*`, not
211
198
  `workspace(w => w.table(...))`. `workspace(name)` returns a named `new Xano()`;
212
199
  tables are `table({ schema: { col: f.text() } })`.
213
- - **Foreign key is `f.tableRef(table)`, not `ref`.** `ref(name)` references a
214
- stack variable (a value); `f.tableRef` is the column constructor.
215
200
  - **Reference-helper picker:** `ref` = stack var (`as:` output), `inp` = input,
216
201
  `col` = table column (in `db.query` `where`), `auth("id")` = the caller,
217
202
  `c.*` = a constant. Pick by what you're pointing at.
203
+ - **Foreign key is `f.tableRef(table)`, not `ref`.** `ref(name)` references a
204
+ stack variable (a value); `f.tableRef` is the column constructor.
218
205
  - **Tagged values are DATA — a JS template literal cannot compose them.** `${ref(...)}`
219
206
  (or `"a" + ref(...)`) stringifies the tag object at BUILD time: ``c.text(`Hi ${ref("u.name")}`)``
220
207
  type-checks and encodes the literal text `Hi [object Object]`, served verbatim.
221
208
  `export()` warns; `--strict` fails. Compose at RUNTIME:
222
209
  `withFilters(c.text("Hi "), fl.concat(ref("u.name")))`, an `obj({...})`/record member,
223
210
  or `c.expression('"Hi, " ~ $var.u.name')`.
224
- - **`s.api.call` / `s.task.call` / `s.trigger.call` / `s.workflow_test.call` are
225
- WORKFLOW-TEST ONLY.** Outside a `workflowTest({...})` stack the engine cannot reach
226
- the target, so one in a query/function/task deploys clean and then answers the first
227
- real request with `ERROR_FATAL: <Type> does not exist: <type>:<n>` — and not per
228
- host kind, the same call fails identically from a function a query runs.
229
- `export()` refuses them. `s.function.call`, `s.tool.call`, `s.middleware.call` and
230
- `s.addon.call` run anywhere, as does `s.function.run` (the ordinary function
231
- invocation). To share logic between endpoints, put it in a `defineFunction` and
232
- `s.function.run` it from both.
233
- - **`expect.*` is a unit-test assertion; `s.expect.*` is a workflow-test statement.**
234
- A statement's `mock` is keyed by TEST NAME; an undeclared name throws. See `llms/tests.md`.
235
- - **Drilling into a maybe-null `db.get` result 500s — use `ref(path, { safe: true })`.**
236
- `db.get` binds `null` on a no-match, but a nested `ref("owner.user_id")` resolves
237
- `$owner.user_id` in one lookup and raises a runtime "Unable to locate var" (HTTP 500)
238
- when `owner` is null — so an ownership/existence guard throws instead of failing
239
- cleanly. Either guard existence first (`expr(ref("owner"), "!=", c.null())`,
240
- or a `db.has`/`db.query`-count precondition), or drill with the null-safe opt-in:
241
- `expr(ref("owner.user_id", { safe: true }), "=", auth("id"))` compiles through the
242
- `get` filter and yields `null` (guard reads `false`) rather than 500ing.
243
- Works inside `obj({...})` too no per-member `s.set_var` hoist needed.
244
- EXPRESSION and `obj()` operands ONLY never a `db.*` match argument. `null`
245
- is not a legal `fieldValue`/`id`, so a chained get (fetch the child row, then
246
- fetch its parent to check the owner) fails with HTTP 400 `Missing param:
247
- field_value` one statement BEFORE the guard the same net effect as the 500,
248
- with an internal-sounding status the caller should never see. Only an id
249
- matching no row reaches it, so it deploys clean. In the chained case guarding
250
- existence first is MANDATORY, not a stylistic alternative: precondition on
251
- `expr(ref("ticket"), "!=", c.null())`, then drill WITHOUT `safe` (the base is
252
- known non-null by then). `export()` warns on a safe ref in that position.
253
- That is for a base that EXISTS and may be null. If the base binds nothing at all
254
- (a typo), `{ safe: true }` turns the 500 into a silent `null` and buries the bug —
255
- `export()` warns on a `ref()` whose base segment no `as` in that stack binds, with a
256
- did-you-mean; fix the name instead.
211
+ - **Declare inputs with `input.<type>()`, read them with `inp("name")`.** `inp()`
212
+ resolves ONLY against this def's own `input` block a value produced earlier in
213
+ the stack is `ref("var.field")`, not `inp("field")`. An undeclared name fails at
214
+ runtime with `Unable to locate input` (`export()` warns).
215
+ - **`get_input`/`get_raw_input` read the whole payload**, not one named input
216
+ (args are `{ as?, encoding?, excludeMiddleware? }` — no `name`). For a single
217
+ input use `inp("name")`.
218
+ - **To match every row, omit `where` never pass a constant.** `where: c.bool(true)` is
219
+ not "no filter": a bare scalar literal states no condition and the engine reads the
220
+ resulting `context.search` as garbage, so it throws. An absent `where` is how "every
221
+ row" is spelled. A raw `Value` `where` stays the escape hatch for a clause built
222
+ elsewhere `inp("clause")`, `ref("built_where")`, or `c.text("id > 0")`.
223
+
224
+ ### Tables and columns
225
+
226
+ - **System columns are auto-injected.** `id` + `created_at` are prepended to
227
+ every table (`system: true` by default); declaring them by hand is redundant.
228
+ `id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.
229
+ Both are valid targets wherever a column name is accepted — `db.query` `sort`/
230
+ `output`, a `db.get`/`edit`/`del` `fieldName`, etc. (the column-name type is
231
+ `keyof schema | "id" | "created_at"`), and both appear in `InferRow<typeof table>`.
232
+ - **A column named `run` is reserved.** The table deploys and reads back fine, then
233
+ EVERY `s.db.add` into it 400s at any column type, with or without a value — and
234
+ the error names the column while complaining about the VALUE. Use `run_id`. Exact,
235
+ case-sensitive, one name: `Run`/`runs`/`run_id` are fine. `--strict` fails on it.
236
+ - **Self-referencing tables** need the bare-name form: inside `tweets`'s own
237
+ schema, write `f.tableRef("tweets", { type: "int" })` the `const tweets`
238
+ handle isn't assigned yet, so the handle form throws "used before declaration".
239
+ - **Seed a table's starting rows with `table({ seed })`.** Rows typed against the
240
+ schema, inline (`seed: [{ name: "…" }]`) or from a file (`seedFile("./seed.json",
241
+ import.meta.url)`). Only `deploy` ships them, and a re-deploy re-seeds cleanly. Never
242
+ put secrets in `seed`. Row shape, `id` pinning, and the file rules: `llms/fields.md`.
243
+ - **`f.password()` defaults to `access: "internal"`, so `db.get` does NOT return it.**
244
+ A login stack that reads `ref("u.password")` after a plain `db.get` fails at runtime
245
+ with `Unable to locate var: u.password` — the column is simply absent from the row.
246
+ Name it in the read's `output` to pull it: `s.db.get({ table: users, fieldName: "email",
247
+ fieldValue: inp("email"), output: ["id", "email", "password"], as: "u" })`, then
248
+ `s.security.check_password`. `output` OVERRIDES column visibility — it is the only way to
249
+ read an `internal` column, and `export()` warns when a stack reads one a `db.get` did not
250
+ return.
251
+ - **Don't take a password through `input.password` on login — it double-hashes.**
252
+ An `f.password()` column hashes on write, and `input.password` *also* hashes the
253
+ submission on bind, so `s.security.check_password` compares two different hashes
254
+ and a correct password always fails (`ok:false` on a found row). Take the submitted
255
+ password as `input.text()` on both signup and login and pass the plaintext straight
256
+ to `check_password` (which does the comparison hash itself). `export()` warns.
257
+
258
+ ### Reading and writing rows
259
+
257
260
  - **DB reads are field-match, not `where`-expr.** `db.get`/`db.edit`/`db.del`/
258
261
  `db.has`/`db.patch` match one field: `{ fieldName, fieldValue }` (`fieldName`
259
262
  defaults to the PK `id`). Only `db.query` takes a `where`/`additionalWhere`
@@ -267,44 +270,73 @@ Non-obvious authoring rules:
267
270
  check-in — use `db.query({ where: [expr(col("habit"), "=", ...), expr(col("date"), "=", ...)], as })`
268
271
  (a `where` array is ANDed) and branch on the result, rather than pushing the
269
272
  check to the client.
270
- - **A column named `run` is reserved.** The table deploys and reads back fine, then
271
- EVERY `s.db.add` into it 400s at any column type, with or without a value — and
272
- the error names the column while complaining about the VALUE. Use `run_id`. Exact,
273
- case-sensitive, one name: `Run`/`runs`/`run_id` are fine. `--strict` fails on it.
274
- - **System columns are auto-injected.** `id` + `created_at` are prepended to
275
- every table (`system: true` by default); declaring them by hand is redundant.
276
- `id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.
277
- Both are valid targets wherever a column name is accepted — `db.query` `sort`/
278
- `output`, a `db.get`/`edit`/`del` `fieldName`, etc. (the column-name type is
279
- `keyof schema | "id" | "created_at"`), and both appear in `InferRow<typeof table>`.
280
- - **Seed a table's starting rows with `table({ seed })`.** `seed` takes rows
281
- typed against the table's schema as a WRITE shape (a column without
282
- `required: true`, and the system columns, may be omitted; `null` needs
283
- `nullable: true`) inline (`seed: [{ name: "…" }]`), a FILE
284
- (`seed: seedFile("./seed.json", import.meta.url)`; path resolves against the DECLARING
285
- file), or a thunk (`seed: () => import("./seed.json")`, async ok, `.default` unwrapped).
286
- Inline rows are TYPED against the schema at compile time; a `seedFile`/thunk seed is
287
- NOT the JSON is opaque to the typechecker, and the `xanots export`/`deploy` CLI
288
- validates it instead, naming the row index, the offending column, and the known
289
- columns. Prefer `seedFile` for a file: a thunk's `import()` sits in
290
- YOUR module, so a bundler emits the JSON as a served chunk. NEITHER is a secrecy
291
- boundary a bundler that inlines the asset ships them either way. Never put secrets
292
- in `seed`. `deploy --static` REFUSES a build carrying internal/sensitive seed values;
293
- `--allow-seed-in-static` overrides for deliberately public demo credentials.
294
- Deploy is a full replace, so re-deploying re-seeds
295
- cleanly (no duplication). Omit `id` and rows auto-number `1..N` (int PK) or take
296
- a stable derived uuid (uuid PK); supplying `id` pins it (engine preserves it,
297
- resets an int sequence past the max). All-or-nothing mixing explicit and
298
- omitted `id` throws. A `system:false` PK is the author's to supply. Pinning is
299
- `seed`-only `s.db.bulk.add` DROPS `id` unless `allowIdField: true`.
300
- - **`use_xdo` storage mode.** Workspace setting (`registerWorkspace({ use_xdo })`,
301
- default `false`) controlling whether fields are stored as JSON under the `xdo`
302
- column (`true`, adds a `gin(xdo)` index) or as real columns (`false`, no gin).
303
- Tables inherit it; override per-table with `table({ useXdo })`. Resolved at
304
- `export()`, so the workspace and tables can be registered in any order.
305
- - **Self-referencing tables** need the bare-name form: inside `tweets`'s own
306
- schema, write `f.tableRef("tweets", { type: "int" })` the `const tweets`
307
- handle isn't assigned yet, so the handle form throws "used before declaration".
273
+ - **Drilling into a maybe-null `db.get` result 500s use `ref(path, { safe: true })`.**
274
+ `db.get` binds `null` on a no-match, but a nested `ref("owner.user_id")` raises a runtime
275
+ "Unable to locate var" (HTTP 500) when `owner` is null an ownership guard throws instead
276
+ of failing cleanly. Two correct shapes:
277
+ - Guard existence first `s.precondition({ expr: expr(ref("owner"), "!=", c.null()),
278
+ error_type: "notfound", error: c.text("Not found.") })` then drill WITHOUT `safe`.
279
+ - Or drill null-safe inside an EXPRESSION or `obj()` operand:
280
+ `expr(ref("owner.user_id", { safe: true }), "=", auth("id"))` yields `null` (the guard
281
+ reads `false`) instead of 500ing. Works inside `obj({...})` too no `s.set_var` hoist needed.
282
+ ⚠ EXPRESSION and `obj()` operands ONLY never a `db.*` match argument. `null` is not a
283
+ legal `fieldValue`/`id`, so a chained get (fetch the child row, then its parent to check
284
+ the owner) fails with HTTP 400 `Missing param: field_value` one statement BEFORE the guard.
285
+ There guarding existence first is MANDATORY. `export()` warns on a safe ref in that position.
286
+ That is for a base that EXISTS and may be null. If the base binds nothing at all (a typo),
287
+ `{ safe: true }` buries the bug as a silent `null` — `export()` warns with a did-you-mean;
288
+ fix the name instead.
289
+
290
+ ### Stacks and calls
291
+
292
+ - **Block specials nest a `body`, not a `stack`.** `s.for`/`s.foreach`/
293
+ `s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`
294
+ take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);
295
+ `s.group(body)` and `s.util.post_process(body)` take it **positionally**.
296
+ `s.for` is **count-bounded** (`{ as, count: <Value>, body }`), not from/to. See the
297
+ authored signatures in `llms/statements-data.md`.
298
+ - **`s.api.call` / `s.task.call` / `s.trigger.call` / `s.workflow_test.call` are
299
+ WORKFLOW-TEST ONLY.** Outside a `workflowTest({...})` stack the engine cannot reach
300
+ the target, so one in a query/function/task deploys clean and then answers the first
301
+ real request with `ERROR_FATAL: <Type> does not exist: <type>:<n>` and not per
302
+ host kind, the same call fails identically from a function a query runs.
303
+ `export()` refuses them. `s.function.call`, `s.tool.call`, `s.middleware.call` and
304
+ `s.addon.call` run anywhere, as does `s.function.run` (the ordinary function
305
+ invocation). To share logic between endpoints, put it in a `defineFunction` and
306
+ `s.function.run` it from both.
307
+ - **`expect.*` is a unit-test assertion; `s.expect.*` is a workflow-test statement.**
308
+ A statement's `mock` is keyed by TEST NAME; an undeclared name throws. See `llms/tests.md`.
309
+ - **A helper returning `Statement[]` widens the stack and kills `InferResponse`.**
310
+ Spreading `...requireX()` where the helper is typed `Statement[]` drops the stack's TUPLE
311
+ type, so EVERY `ref()`/`as` in that stack — even ones after the spread — resolves to
312
+ `unknown` and the response types as `StackTupleWidened`. Nothing fails at the helper; it
313
+ surfaces where the response is consumed, often a frontend typecheck. Fix: return
314
+ `statements(s.a(...), s.b(...))` — its tuple survives the spread. A helper that builds
315
+ its array in a LOOP cannot be a tuple; declare `responseShape` on the query there.
316
+ - **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**
317
+ The regex filters (`regex_test`/`regex_match`/`regex_replace`/…) are PHP `preg_*`: the
318
+ piped value is the PATTERN and must be delimiter-wrapped, and the ARGUMENT is the subject.
319
+ Correct: `withFilters(c.regex(/^[a-z-]+$/i), fl.regex_test(inp("slug")))`.
320
+ - A bare `c.text("^…$")` is an invalid pattern that matches *nothing* for every input,
321
+ so a precondition on it silently rejects all values. `c.regex(body, "i")` wraps +
322
+ escapes it for you (a JS `RegExp` too: `c.regex(/^…$/i)`).
323
+ - Reversed — subject piped, pattern in the argument — reads correctly, type-checks, and
324
+ answers false for EVERY input, so an `if (matches) reject` guard admits what it refuses.
325
+ - Build time refuses both: `withFilters` throws on a bare `c.text` pattern from ANY position
326
+ in the chain (a normalizer in front of the regex filter is refused too; nothing upstream
327
+ adds the delimiters) and on a pattern found in the subject slot; `s.expect.to_match` is the
328
+ same PATTERN slot, refused both ways; a `ref`/`inp` pattern is passed through untouched.
329
+ `export()` warns on a reversed pair in stored bytes.
330
+ - Better still: a native typed input (`input.email`) over hand-rolled validation.
331
+ - **An `auth()`-keyed limiter collapses on any host with no caller identity — at every**
332
+ **tier.** `s.redis.ratelimit({ key: withFilters(c.text("rl:"), fl.concat(auth("id"))) })`
333
+ is the per-user form, but `auth()` is `null` on a public query, a task, or a function,
334
+ and every caller then shares one bucket. Attaching it once at
335
+ `apiGroup({ middleware })` or the workspace tier is the tempting DRY move and inherits
336
+ the same collapse onto every member endpoint. Export warns, naming the tier.
337
+
338
+ ### Identity, build, and deploy
339
+
308
340
  - **Same-name siblings collide — queries excepted: their identity carries group + verb.**
309
341
  Guids derive from `(type, name)`, so two functions (or tables, toolsets, …) sharing
310
342
  a name derive ONE guid and `export()` throws — give them DISTINCT names. A QUERY
@@ -315,137 +347,34 @@ Non-obvious authoring rules:
315
347
  is NOT a lasting fix: a lock entry cannot hold two guids, so `export --lock`
316
348
  refuses the pair (`export()` warns even unlocked); it is for pinning identity
317
349
  across a rename, not for sharing a name.
318
- - **`export()` vs `emitBundle()` vs `writeBundle()`:** `writeBundle(app, path)`
319
- writes it to disk; `export()` returns the bundle object;
320
- `emitBundle()` returns the pretty JSON string. All three run the SAME build-time
321
- checks, INCLUDING seed validation of a literal `seed: [...]` array (row shape,
322
- unknown column, coercion, enum membership, the all-or-nothing `id` rule). A
323
- DEFERRED seed a thunk (`() => import("./seed.json")`) or `seedFile()` needs an
324
- await or the filesystem, so it is materialised and checked only by the
325
- `xanots export`/`deploy` CLI path. Seed rows are never emitted into the bundle
326
- either way; only `deploy` ships them. The `node:fs` writers
327
- (`writeBundle`/`writeArtifact`) and lock-file I/O import from `@xanots/sdk/node`,
328
- NOT the browser-safe `@xanots/sdk` entry (a frontend imports defs from it for
329
- `getPath()`/`InferInput`).
330
- The compiler machinery (per-kind `encode*`, the registries, the bundle serializer,
331
- the lock model) is on `@xanots/sdk/internal` and is never needed to author.
332
- READING a bundle back is `@xanots/sdk/bundle` — a statement walker (`2.if.0` paths),
333
- a structural hash, `mvp:*` catalog, `tableRefOf`.
334
- - **Client bundle size / tree-shaking.** `@xanots/sdk` is `sideEffects: false` and pulls
335
- no Node built-ins, so a bundler drops unused SDK exports. But importing a **def** for its
336
- `getPath()`/`verb`/`getUrl()`/`getChannel()` also pulls whatever its `stack` references:
337
- the `s.*`/`c.*` factory CALLS run at module load to BUILD it. Types are free.
338
- ⚠ A FLOOR — **~267 kB minified (~65 kB gzipped)** for the FIRST def; splitting modules
339
- never removes it. The floor is the RUNTIME, not the def: a second or much richer def
340
- adds ~2 kB, so trimming a def does not shrink it.
341
- Fix: `xanots routes <entry> --emit xano/routes.gen.ts` (`paths` is an accepted alias) — verbs, paths, and sockets as
342
- plain data importing NOTHING, still compile-checked: `routePath("GET blog/{slug}", { slug })`
343
- `channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
344
- URLs lifted to `wss://h/ws/<tenant>:<canonical>`). A rename is a type error, not a 404.
350
+ - **`export()` vs `emitBundle()` vs `writeBundle()`:** `export()` returns the bundle object,
351
+ `emitBundle()` the pretty JSON string, `writeBundle(app, path)` writes it to disk. All
352
+ three run the SAME build-time checks, INCLUDING seed validation of a literal `seed: [...]`
353
+ array (row shape, unknown column, coercion, enum membership, the all-or-nothing `id`
354
+ rule). A DEFERRED seed a thunk (`() => import("./seed.json")`) or `seedFile()` — is
355
+ checked only by the `xanots export`/`deploy` CLI path. Seed rows are never emitted into
356
+ the bundle either way; only `deploy` ships them.
357
+ - Entry points: the `node:fs` writers (`writeBundle`/`writeArtifact`) and lock-file I/O
358
+ import from `@xanots/sdk/node`, NOT the browser-safe `@xanots/sdk` entry (a frontend
359
+ imports defs from it for `getPath()`/`InferInput`). `@xanots/sdk/internal` is the
360
+ compiler machinery, never needed to author. READING a bundle back is `@xanots/sdk/bundle`
361
+ — a statement walker (`2.if.0` paths), a structural hash, `mvp:*` catalog, `tableRefOf`.
345
362
  - **Intra-workspace imports use `.js` specifiers** (`../tables/links.js`), not
346
363
  extensionless — the defs compile under `moduleResolution: bundler`. Add the `.js`.
347
- - **Verifying a def outside a bundler.** Inside a bundler (Vite/webpack) importing a
348
- query def to read `getPath()`/`verb` works directly. To spot-check from Node, run a REAL
349
- file with `tsx <file.ts>` **from inside the project root** — not `tsx -e "import …"`
350
- (its CJS-preparse mis-resolves the package `exports` map → ERR_PACKAGE_PATH_NOT_EXPORTED),
351
- and not bare `node file.ts` (chokes on the `.js`-specifier intra-workspace imports the
352
- xanots CLI's own loader resolves). Running from outside the project root also breaks
353
- the `@xanots/sdk` specifier resolution.
354
- - **A helper returning `Statement[]` widens the stack and kills `InferResponse`.**
355
- Factoring a repeated guard into `function requireX(): Statement[]` and spreading it
356
- (`...requireX("d")`) drops the stack's TUPLE type, so EVERY `ref()`/`as` in that stack —
357
- including ones declared after the spread — resolves to `unknown` and the query's
358
- response types as `StackTupleWidened`. Nothing fails at the helper: the error surfaces
359
- wherever the response is consumed, often a frontend package's typecheck. Fix: return
360
- `statements(s.a(...), s.b(...))` — a const-generic identity export whose tuple survives
361
- the spread. A helper that builds its array in a LOOP cannot be a tuple; declare
362
- `responseShape` on the query there. See `llms/statements-runtime.md`.
363
- - **Block specials nest a `body`, not a `stack`.** `s.for`/`s.foreach`/
364
- `s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`
365
- take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);
366
- `s.group(body)` and `s.util.post_process(body)` take it **positionally**.
367
- `s.for` is **count-bounded** (`{ as, count: <Value>, body }`), not from/to. See the
368
- authored signatures in `llms/statements-data.md`.
369
- - **MCP servers & agents are distinct root kinds** that both persist under the
370
- `toolset` payload key (so a same-name pair collides). `mcpServer({...})` exposes
371
- tools over MCP (auth is per-tool — no server-level gate); `agent({...})` carries a
372
- typed `llm` block — and so may an `mcpServer`, since the two are ONE stored
373
- object distinguished by `type`. Their `tools` take a `tool()` handle (or name), resolved to the
374
- tool's guid like the call family; a raw numeric `id` is an escape hatch.
375
- - **`task.schedule` is an array** of `{ startsOn, freq?, repeatEnabled?, endsOn?, endsEnabled? }`
376
- (`ScheduleDef[]`), not a single `{ type, value }`. `freq` is seconds; `startsOn`/
377
- `endsOn` are timestamp strings validated at encode time — `"2026-01-01T00:00:00Z"`, or
378
- the space-separated `"2026-01-01 00:00:00+0000"` a pulled workspace carries. Never
379
- epoch numbers, and never zoneless: the engine stores the string verbatim without
380
- checking it, so one it cannot parse deploys clean and the task then never fires.
381
- - **`get_input`/`get_raw_input` read the whole payload**, not one named input
382
- (args are `{ as?, encoding?, excludeMiddleware? }` — no `name`). For a single
383
- input use `inp("name")`.
384
- - **`f.password()` defaults to `access: "internal"`, so `db.get` does NOT return it.**
385
- A login stack that reads `ref("u.password")` after a plain `db.get` fails at runtime
386
- with `Unable to locate var: u.password` — the column is simply absent from the row.
387
- Name it in the read's `output` to pull it: `s.db.get({ table: users, fieldName: "email",
388
- fieldValue: inp("email"), output: ["id", "email", "password"], as: "u" })`, then
389
- `s.security.check_password`. `output` OVERRIDES column visibility — it is the only way to
390
- read an `internal` column, and `export()` warns when a stack reads one a `db.get` did not
391
- return.
392
- - **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**
393
- The regex filters (`regex_test`/`regex_match`/`regex_replace`/…) are pattern-piped
394
- PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped, and the
395
- ARGUMENT is the subject. A bare `c.text("^…$")` is an invalid pattern that matches
396
- *nothing* for every input, so a precondition on it silently rejects all values.
397
- `c.regex(body, "i")` wraps + escapes it for you (a JS `RegExp` too: `c.regex(/^…$/i)`).
398
- Reversed — subject piped, pattern in the argument — reads correctly, type-checks, and
399
- answers false for EVERY input, so an `if (matches) reject` guard admits what it
400
- refuses. `withFilters` throws on a bare `c.text` pattern from ANY position in
401
- the chain (a normalizer in front of the regex filter is refused too; nothing upstream
402
- adds the delimiters) and on a pattern found in the subject slot; `s.expect.to_match`
403
- is the same PATTERN slot, refused both ways; a `ref`/`inp` pattern is passed through untouched.
404
- `export()` warns on a reversed pair in stored bytes. Better still:
405
- a native typed input (`input.email`) over hand-rolled validation.
406
- - **Compose a rule set as SIBLINGS, not a folded chain.** `and(...rules)` takes any
407
- number of terms and encodes flat; `rules.reduce((acc, r) => and(acc, r))` nests one
408
- container per rule, which costs quadratic bytes (512 terms: 394 KiB flat, 21 MiB
409
- folded) and is refused past 128 levels. Mixed joins: `and(or(...anyOf), ...allOf)`.
410
- - **Declare inputs with `input.<type>()`, read them with `inp("name")`.** `inp()`
411
- resolves ONLY against this def's own `input` block — a value produced earlier in
412
- the stack is `ref("var.field")`, not `inp("field")`. An undeclared name fails at
413
- runtime with `Unable to locate input` (`export()` warns).
414
- - **Don't take a password through `input.password` on login — it double-hashes.**
415
- An `f.password()` column hashes on write, and `input.password` *also* hashes the
416
- submission on bind, so `s.security.check_password` compares two different hashes
417
- and a correct password always fails (`ok:false` on a found row). Take the submitted
418
- password as `input.text()` on both signup and login and pass the plaintext straight
419
- to `check_password` (which does the comparison hash itself). `export()` warns.
420
364
  - **Agents authenticate with env vars — never `xanots login`.** `login` blocks on a
421
- browser consent no agent can complete. Set `$XANO_INSTANCE_URL` + `$XANO_WORKSPACE_ID`
422
- + `$XANO_META_TOKEN` and run `deploy`/`release` directly: no disk, no rotation, so it
423
- survives repeated runs. It outranks every other credential (`--config` and
424
- `$XANO_REFRESH_TOKEN` included, each named on stderr when displaced), and setting SOME
425
- of the three is a hard error a misspelled secret must not fall through to whatever
426
- credential the runner has. As a file that triple is `{ "type": "token",
427
- "instance_base_url": …, "workspace_id": <n>, "meta_api_token": … }`. The older
428
- `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works but ROTATES: single-use.
365
+ browser consent no agent can complete. Set all three of `$XANO_INSTANCE_URL`,
366
+ `$XANO_WORKSPACE_ID`, `$XANO_META_TOKEN` and run `deploy`/`release` directly: no disk, no
367
+ rotation, so it survives repeated runs.
368
+ - The triple outranks every other credential (`--config` and `$XANO_REFRESH_TOKEN` included,
369
+ each named on stderr when displaced). Setting SOME of the three is a hard error, so a
370
+ misspelled secret cannot fall through to whatever credential the runner has.
371
+ - As a file: `{ "type": "token", "instance_base_url": …, "workspace_id": <n>,
372
+ "meta_api_token": … }`. The older `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still
373
+ works but ROTATES: single-use.
429
374
  - **Event-driven objects fire on an EPHEMERAL.** A `task` (scheduled), an `mcpServer`,
430
375
  and every trigger — `tableTrigger` included — run normally on an ephemeral env, which
431
376
  is where `deploy` sends them. So test an event-driven design (screen-on-insert, cron
432
377
  cleanup, MCP tool call) by deploying it and letting it run.
433
- - **Zero-based numeric keys make `c.obj` a LIST.** `c.obj({ "0": "a" })` evaluates to
434
- `["a"]`: a numeric key IS an index in the engine's data model, so keys that are exactly
435
- `0..n-1` come back as a list with HTTP 200 and no error. Write `c.array([...])` when you
436
- want the list, or prefix the keys (`"k0"`) when you want an object. A non-zero-based
437
- numeric key (`{ "2": … }`) survives as a key. Export warns.
438
- - **To match every row, omit `where` — never pass a constant.** `where: c.bool(true)` is
439
- not "no filter": a bare scalar literal states no condition and the engine reads the
440
- resulting `context.search` as garbage, so it throws. An absent `where` is how "every
441
- row" is spelled. A raw `Value` `where` stays the escape hatch for a clause built
442
- elsewhere — `inp("clause")`, `ref("built_where")`, or `c.text("id > 0")`.
443
- - **An `auth()`-keyed limiter collapses on any host with no caller identity — at every**
444
- **tier.** `s.redis.ratelimit({ key: withFilters(c.text("rl:"), fl.concat(auth("id"))) })`
445
- is the per-user form, but `auth()` is `null` on a public query, a task, or a function,
446
- and every caller then shares one bucket. Attaching it once at
447
- `apiGroup({ middleware })` or the workspace tier is the tempting DRY move and inherits
448
- the same collapse onto every member endpoint. Export warns, naming the tier.
449
378
 
450
379
  ## Statements
451
380
 
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xanots",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.",
5
5
  "coverage": {
6
6
  "objectKinds": {
@@ -77,7 +77,7 @@
77
77
  {
78
78
  "name": "c.obj",
79
79
  "signature": "(o?: Json | null) => Value",
80
- "description": "Object constant → tag \"const:obj\". A populated one stores an empty {} carrying one `set` filter per key — the editor's form, and the only populated form the engine reads back (a populated JSON string arrives truncated and fails the request with ERROR_FATAL \"Unable to decode.\"). ⚠ a ZERO-BASED numeric key is an INDEX in the engine's data model, so c.obj({\"0\":\"a\"}) evaluates to the list [\"a\"] (a non-zero-based one like {\"2\":…} survives as a key) — that is the platform, not this encoding. No argument = the empty object {} — use this one. Explicit null = the legacy blank form the engine evaluates to null, NOT {}; it exists only so a pulled workspace round-trips, do not author it. Plain JSON literals only — a nested tagged value (inp/ref/auth/c.*) is rejected; for a computed object response use a record of values, not c.obj."
80
+ "description": "Object constant → tag \"const:obj\". A populated one stores an empty {} carrying one `set` filter per key — the editor's form, and the only populated form the engine reads back (a populated JSON string arrives truncated and fails the request with ERROR_FATAL \"Unable to decode.\"). ⚠ a ZERO-BASED numeric key is an INDEX in the engine's data model, so c.obj({\"0\":\"a\"}) evaluates to the list [\"a\"] (a non-zero-based one like {\"2\":…} survives as a key) — that is the platform, not this encoding. Write c.array([...]) for a list, or prefix the keys (\"k0\") for an object; `export()` warns. No argument = the empty object {} — use this one. Explicit null = the legacy blank form the engine evaluates to null, NOT {}; it exists only so a pulled workspace round-trips, do not author it. Plain JSON literals only — a nested tagged value (inp/ref/auth/c.*) is rejected; for a computed object response use a record of values, not c.obj."
81
81
  },
82
82
  {
83
83
  "name": "c.array",
@@ -103,7 +103,7 @@
103
103
  {
104
104
  "name": "obj",
105
105
  "signature": "(fields: Record<string, Value | nested>) => ObjValue<typeof fields>",
106
- "description": "Dynamic object value → tag \"const:expr2\" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/col values, env()/setting()/sys.*, c.now(), c.* constants, nested records, or arrays — and each member may carry a FILTER CHAIN (withFilters + fl.*), which renders as the expression pipe `$var.row|get:\"a.b\"`. That matters most for the null-safe drill: db.get binds null on a miss, so ref(path, { safe: true }) inside an obj() is the normal shape, not a workaround — you do NOT need a preceding s.set_var to hoist it. The member record rides the return type, so InferResponse resolves each member the way it resolves a top-level response key — `response: { user: obj({ id: ref(\"row.id\") }) }` derives `{ user: { id: Col | null } }`, and a raw nested object literal (`response: { user: { id: ref(\"row.id\") } }`, auto-wrapped through this) derives the same. A constant record or list has two spellings that both work and render identically: bare (`{ a: 1 }`, `[]`) or `c.obj(...)`/`c.array([...])` — prefer the bare form. NEST WITH A RAW RECORD, not an inner obj() call: an inner call yields a const:expr2 value, which the expression serializer has no spelling for and THROWS. The legacy blank `c.obj(null)` is refused here (it evaluates to null, not {}) — write c.null() or c.obj(). Still rejected: a filter ARGUMENT that carries its own chain or is a c.now() (a trailing | binds to the whole value, not one argument, and c.now() needs one), a DISABLED filter (an expression string cannot record that), and the output/response/toolset/reg tags — build those in a prior step and ref() them. Use for e.g. s.ai.agent.run args."
106
+ "description": "Dynamic object value → tag \"const:expr2\" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/col values, env()/setting()/sys.*, c.now(), c.* constants, nested records, or arrays — and each member may carry a FILTER CHAIN (withFilters + fl.*), which renders as the expression pipe `$var.row|get:\"a.b\"`. A null-safe ref(path, { safe: true }) works inside an obj() member directly. The member record rides the return type, so InferResponse resolves each member the way it resolves a top-level response key — `response: { user: obj({ id: ref(\"row.id\") }) }` derives `{ user: { id: Col | null } }`, and a raw nested object literal (`response: { user: { id: ref(\"row.id\") } }`, auto-wrapped through this) derives the same. A constant record or list has two spellings that both work and render identically: bare (`{ a: 1 }`, `[]`) or `c.obj(...)`/`c.array([...])` — prefer the bare form. NEST WITH A RAW RECORD, not an inner obj() call: an inner call yields a const:expr2 value, which the expression serializer has no spelling for and THROWS. The legacy blank `c.obj(null)` is refused here (it evaluates to null, not {}) — write c.null() or c.obj(). Still rejected: a filter ARGUMENT that carries its own chain or is a c.now() (a trailing | binds to the whole value, not one argument, and c.now() needs one), a DISABLED filter (an expression string cannot record that), and the output/response/toolset/reg tags — build those in a prior step and ref() them. Use for e.g. s.ai.agent.run args."
107
107
  },
108
108
  {
109
109
  "name": "ref",
@@ -309,7 +309,7 @@
309
309
  "kind": "workflow_test",
310
310
  "payloadKey": "workflow_test",
311
311
  "authorFactory": "workflowTest",
312
- "description": "An end-to-end test: a named stack with NO input and NO response that invokes other objects (`s.function.call`, `s.task.call`, `s.api.call`) and asserts on what they bind with `s.expect.*`. `datasource` defaults to `\"\"` (an EMPTY datasource, recommended); naming one makes the engine CLONE that datasource before every run, so pointing a test at production-sized data can be slow enough to fail the run — `\"live\"` warns at compile time.",
312
+ "description": "An end-to-end test: a named stack with NO input and NO response that invokes other objects (`s.function.call`, `s.task.call`, `s.api.call`) and asserts on what they bind with `s.expect.*`. `datasource` defaults to `\"\"` (an EMPTY datasource, recommended); naming one is the clone trap described in `llms/kinds-core.md`.",
313
313
  "registerMethod": "registerWorkflowTests",
314
314
  "registered": true
315
315
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xanots/sdk",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "XanoTS — your Xano backend as TypeScript. `xanots deploy` ships your typed workspace (and an optional static frontend) to a live, auto-expiring ephemeral environment and prints its URL. init → deploy → URL.",
5
5
  "keywords": [
6
6
  "xanots",