@xanots/sdk 0.0.10 → 0.0.11

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 (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +38 -0
  3. package/dist/.build-fingerprint +1 -1
  4. package/dist/bin.js +7 -6
  5. package/dist/bundle.d.ts +218 -0
  6. package/dist/bundle.js +143 -0
  7. package/dist/{chunk-5YCQ2QHH.js → chunk-22TKBSDV.js} +2 -2
  8. package/dist/{chunk-YYFXVYPX.js → chunk-4IF54NU5.js} +3 -3
  9. package/dist/{chunk-W24FJHPD.js → chunk-4Q7ZOHH7.js} +2 -2
  10. package/dist/{chunk-4BXJGVZ3.js → chunk-BC2C5GVI.js} +2 -69
  11. package/dist/chunk-BSK7ELHU.js +70 -0
  12. package/dist/chunk-EHP3WPEG.js +21 -0
  13. package/dist/{chunk-75Z74TA7.js → chunk-G4EJMQLD.js} +2 -2
  14. package/dist/{chunk-I7DQDJAM.js → chunk-HYBN4H3F.js} +38 -46
  15. package/dist/chunk-OHX6MIUZ.js +184 -0
  16. package/dist/{chunk-VKSOTZK3.js → chunk-OWGCOGKK.js} +155 -3
  17. package/dist/{chunk-LBYWGMOA.js → chunk-QK7ZQJLP.js} +140 -20
  18. package/dist/{chunk-5R73LFWK.js → chunk-QYMAZRAU.js} +7 -7
  19. package/dist/{chunk-ACCBOMCB.js → chunk-RCT7UX7B.js} +12 -12
  20. package/dist/{chunk-P6TAVLOX.js → chunk-UOZMSF4C.js} +15 -8
  21. package/dist/{chunk-7JDT4PBU.js → chunk-VAF6A3YD.js} +5 -179
  22. package/dist/{chunk-OYMR5AMJ.js → chunk-XEOX6AM7.js} +2 -2
  23. package/dist/cli.js +6 -5
  24. package/dist/{codegen-command-GB3H2KQ7.js → codegen-command-FUT2KJB6.js} +15 -12
  25. package/dist/codegen.d.ts +2 -1
  26. package/dist/{deploy-command-QTIVA22A.js → deploy-command-IP7V7GT4.js} +8 -7
  27. package/dist/index.d.ts +6 -4
  28. package/dist/index.js +10 -9
  29. package/dist/{init-command-MAXULNAD.js → init-command-NPVL32L6.js} +8 -7
  30. package/dist/internal.d.ts +4 -4
  31. package/dist/internal.js +62 -40
  32. package/dist/io-P2H75UV2.js +12 -0
  33. package/dist/{lock-GFXD6G2E.js → lock-46FWYE4D.js} +3 -2
  34. package/dist/{lock-commands-DQ7CUMIV.js → lock-commands-ZZKZ4LZJ.js} +8 -7
  35. package/dist/node.d.ts +5 -4
  36. package/dist/node.js +12 -11
  37. package/dist/{preflight-command-GZ2GE5RN.js → preflight-command-K346GPTY.js} +8 -7
  38. package/dist/{release-command-YUBTNHVX.js → release-command-IMNTIVWK.js} +14 -13
  39. package/dist/response-BQVQ24l1.d.ts +844 -0
  40. package/dist/{store-BG1UPZ3Z.d.ts → store-BLyNeQ8S.d.ts} +16 -3
  41. package/dist/{upgrade-command-FB5QJ363.js → upgrade-command-BN3EHAOI.js} +8 -7
  42. package/dist/{workspace-command-43P42FBP.js → workspace-command-MNK7Y7MQ.js} +15 -12
  43. package/dist/{response-CVAE2kMj.d.ts → xdo-BjJj5W_E.d.ts} +1 -837
  44. package/guides/typed-frontend.md +1 -1
  45. package/llms/filters.md +11 -10
  46. package/llms/kinds-core.md +1 -1
  47. package/llms/statements-data.md +3 -1
  48. package/llms/values.md +1 -1
  49. package/llms-full.txt +41 -30
  50. package/llms.txt +25 -17
  51. package/manifest.json +2 -2
  52. package/package.json +7 -2
  53. package/dist/io-M7XZEMK7.js +0 -11
@@ -94,7 +94,7 @@ on the query and every caller derives from that single source of truth:
94
94
 
95
95
  ```ts
96
96
  const getPost = query({
97
- verb: "GET", apiGroup: blog, name: "get_post",
97
+ verb: "GET", apiGroup: blog, name: "get_post/{id}", // one row → address it in the path
98
98
  input: { id: input.int({ required: true }) },
99
99
  stack: [s.db.query({ table: post, where: expr(col("id"), "=", inp("id")), as: "rows" })],
100
100
  // A filtered response is opaque to the static walk, so derivation is `unknown`.
package/llms/filters.md CHANGED
@@ -39,12 +39,13 @@ database with a single `s.db.direct_query` UPDATE (`SET clicks = clicks + 1 WHER
39
39
  which the DB applies atomically. Reserve the pipeline form for low-contention counters
40
40
  where a rare lost update is acceptable.
41
41
  ⚠ `direct_query` needs the table's PHYSICAL Postgres name, which the typed surface
42
- does NOT expose: the engine derives a physical name from workspace + table ids (of the
43
- form `x<workspace_id>_<table_id>`, e.g. `x6_203970`), ids assigned at importnot knowable
44
- from a `table()` def (identity is a name + guid, not the numeric id), and `sql_name`
45
- persists empty. So the safe counter drops out of the typed surface: hardcode
46
- the physical name after inspecting the deployed table. A typed atomic path needs an
47
- engine change.
42
+ does NOT expose: the engine derives it from ids assigned at import not knowable from a
43
+ `table()` def (identity is a name + guid, not the numeric id)and `sql_name` persists
44
+ empty. The derived name is also NOT STABLE: a deploy is a full replace, so every table is
45
+ created afresh and the id in its name moves each time, on the same unchanged project. So
46
+ the safe counter drops out of the typed surface: resolve the physical name from
47
+ `information_schema` inside the request that uses it, and never store, cache or hardcode
48
+ one. A typed atomic path needs an engine change.
48
49
 
49
50
  - `fl.add(value: decimal): decimal`
50
51
  - `fl.append(value: <T>, path: text): <T>[]`
@@ -136,11 +137,11 @@ engine change.
136
137
  - `fl.prepend(value: <T>, path: text): <T>[]`
137
138
  - `fl.range(start: int, stop: int): int[]`
138
139
  - `fl.reduce(initial_value: int, code: text, timeout?: int): any[]` — `code` is a JS body run per element; the ACCUMULATOR is `$result` (there is no `$acc`) and `initial_value` is REQUIRED — omitting it would slot the code as the initial value
139
- - `fl.regex_match(subject: text): text[]`
140
- - `fl.regex_match_all(subject: text): text[]`
140
+ - `fl.regex_match(subject: text): text[]` — piped value is the PATTERN, the arg is the subject — see `regex_test`
141
+ - `fl.regex_match_all(subject: text): text[]` — piped value is the PATTERN, the arg is the subject — see `regex_test`
141
142
  - `fl.regex_quote(delimiter?: text): text`
142
- - `fl.regex_replace(replacement: text, subject: text): text`
143
- - `fl.regex_test(subject: text): bool`
143
+ - `fl.regex_replace(replacement: text, subject: text): text` — piped value is the PATTERN, `subject` is the text searched — see `regex_test`. The replacement comes FIRST
144
+ - `fl.regex_test(subject: text): bool` — piped value is the PATTERN (build it with `c.regex(...)`); the arg is the subject — the REVERSE of `contains`/`starts_with`. Swapped, it answers false for every input with no error, so write `withFilters(c.regex("^a+$"), fl.regex_test(inp("s")))` (or name the arg: `fl.regex_test({ subject: inp("s") })`). A pattern found in the subject slot is refused at build time
144
145
  - `fl.round(precision?: int): decimal`
145
146
  - `fl.rtrim(mask?: text): text`
146
147
  - `fl.secureid_decode(salt: text): int`
@@ -13,7 +13,7 @@ to survive a rename).
13
13
  - `verb`: `"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"` (required), UPPERCASE. Anything else — most often a lowercase `"post"` — makes `query()` THROW, because Xano does NOT reject it: it stores the verb as NULL, a null verb serves as GET, and the endpoint then answers on the wrong method while the one you meant 404s `Unable to locate request.`
14
14
  - `apiGroup`: an `apiGroup()` def handle (or its name) — binds by guid, stable across syncs. Raw numeric `apiGroupId?` is the escape hatch and wins if both given.
15
15
  - `auth`: `false` (no auth) or an auth-table id; `responseType`: `"standard" | "stream"` (default `standard`) — any other spelling THROWS, since Xano stores an unrecognized one as NULL and a null buffers as `standard`, so a misspelled stream quietly does not stream.
16
- - `name` is the endpoint PATH within the group. A `{param}` segment is a URL PATH PARAM bound to the input of the same name, and segments chain: `name: "blog/{slug}/review/{review_id}"` + `input: { slug: input.text(), review_id: input.int() }`. Read it with `inp("slug")` like any other input. Every `{param}` MUST have a matching input or `query()` THROWS — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. A `{param}` need NOT be a whole segment (`"blog/post-{slug}"` routes fine), but its type must fit one segment (no object/list/json/file/geo/vector); there are no wildcards or patterns. `required: true` is NOT demanded (the engine's editor leaves path inputs unmarked). Inputs absent from the path are ordinary query-string/body params. Name charset is ONLY `A-Za-z0-9_-/{}`, max 200: a `.` (`"export.zip"`) is NOT rejected by Xano — it stores an EMPTY name that deploys clean then 404s forever, so `query()` THROWS. Use `"export_zip"` and set the extension in the response headers.
16
+ - `name` is the endpoint PATH within the group. A `{param}` segment is a URL PATH PARAM bound to the input of the same name, and segments chain: `name: "blog/{slug}/review/{review_id}"` + `input: { slug: input.text(), review_id: input.int() }`. Read it with `inp("slug")` like any other input. Every `{param}` MUST have a matching input or `query()` THROWS — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. A `{param}` need NOT be a whole segment (`"blog/post-{slug}"` routes fine), but its type must fit one segment (no object/list/json/file/geo/vector); there are no wildcards or patterns. `required: true` is NOT demanded (the engine's editor leaves path inputs unmarked). The CONVERSE is warned, not enforced: an input a `GET`/`DELETE`/`HEAD` looks ONE ROW up by (`s.db.get`/`get_by_id`/`has`/by-field edit/patch/delete) belongs in the path — `export()` warns `query.path-segment-candidate`. It still serves `?blog_id=1`, but the route is not addressable and `getPath()` types STATIC, so a caller cannot pass the value positionally. A segment is any value naming WHICH resource is wanted, not just an id (`"shop/{country}"`). An input that NARROWS A LIST (`s.db.query`) stays a query-string param. Inputs absent from the path are ordinary query-string/body params. Name charset is ONLY `A-Za-z0-9_-/{}`, max 200: a `.` (`"export.zip"`) is NOT rejected by Xano — it stores an EMPTY name that deploys clean then 404s forever, so `query()` THROWS. Use `"export_zip"` and set the extension in the response headers.
17
17
  - **Client recipe:** `q.getPath({ params: { slug: "hello" } })` → `/api:<canonical>/blog/hello` — never interpolate by hand. `getPath` percent-encodes each value (so `?`/`#`/spaces stay in their segment) and throws on what encoding cannot contain: a `/`, and a value that IS `.`/`..` (a URL parser drops those before routing — `%2e` counts — addressing a different endpoint). The keys are typed from the literal `name`, so a typo is a compile error. The HANDLE's `q.toSearchParams(input)` drops path params for a GET; the free `query.toSearchParams(input)` has no view of the route and keeps every key.
18
18
  - `apiGroup({ name, guid?, canonical?, description?, docs?, swagger?, apiGroupEnabled?, documentation?, cors? })` — a query container; register it and bind queries to it via their `apiGroup`.
19
19
  - `cors?`: `{ mode?, allowOrigins?: string[], allowHeaders?: string[], allowCredentials?, maxAge?, allowMethods?: { get?, post?, put?, patch?, delete?, head? } }`.
@@ -12,7 +12,7 @@ primary key `id`):
12
12
 
13
13
  - `s.db.get({ table, fieldName?, fieldValue, lock?, output?, as? })` — one row by field match; `output` restricts returned columns (and overrides column visibility — it can pull `internal` columns like a password hash).
14
14
  - `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` — get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` for an id that names no row. Both spellings are live in pulled workspaces.
15
- - ⚠ `id` is validated `>= 1`, so the `0` sentinel an optional `f.tableRef` stores fails the request with HTTP 400 `Value is less than the minimum value of 1` — it does NOT bind `null`. The throw is not scoped to the lookup: inside a `foreach` it kills the whole request, so one unset FK loses every other row's work. Read a nullable FK with the field-match form, which binds `null` on `0` and lets the loop finish: `s.db.get({ table, fieldName: "id", fieldValue: ref("row.fk"), as })`.
15
+ - ⚠ `id` is validated `>= 1`, so the `0` sentinel an optional `f.tableRef` stores fails the request with HTTP 400 `Value is less than the minimum value of 1` — it does NOT bind `null`. The throw is not scoped to the lookup: inside a `foreach` it kills the whole request, so one unset FK loses every other row's work. Read a nullable FK with the field-match form, which binds `null` on `0` and lets the loop finish: `s.db.get({ table, fieldName: "id", fieldValue: ref("row.fk"), as })`. `export()` warns when the `id` is statically a `0` — the literal `c.int(0)`, an `inp()` whose declared input default is `0`, or a `ref()` to a column declared `default: 0` — and `--strict` fails the build.
16
16
  - `s.db.has({ table, fieldName?, fieldValue, as? })` — existence test.
17
17
  - `s.db.del({ table, fieldName?, fieldValue, as? })` — delete by field match.
18
18
  - `s.db.add({ table, row?, data?, output?, as? })` — insert; `row` is a partial keyed by column.
@@ -54,6 +54,8 @@ primary key `id`):
54
54
  - `distinct` — `"auto"` (default) | `"yes"` | `"no"`, riding `context.return.<list|stream>.distinct`.
55
55
  - `s.db.truncate({ table, reset?, as? })` · `s.db.schema({ table, path, as? })`.
56
56
  - `s.db.direct_query({ sql, responseType?, args?, parser?, as? })` — `sql` is a **raw string** (not a `Value`); binds go in `args: Value[]`. `parser: "template_engine"` renders the body as a template first — how a query interpolates a column or table name a bound arg cannot carry; omit it for the default.
57
+ - Template placeholders are Twig over the request scope: `{{ $input.name }}` for an input, `{{ $var.name }}` for a stack variable. ⚠ A BARE `{{ name }}` renders as the empty string — HTTP 200, no error, a query that silently ran with a blank where the value belonged. A bound `?` arg carries a VALUE without the template at all.
58
+ - ⚠ A table's PHYSICAL name is **not stable across deploys**. A deploy is a full replace, so every table is created afresh and the id in its name moves every time — the same unchanged project redeployed three times gave one table three different names. Never store, cache, hardcode or fixture one: resolve it from `information_schema` inside the same request that uses it.
57
59
  - `s.db.external.<engine>.direct_query({ sql, connectionString, responseType?, args?, parser?, as? })` — same shape against an EXTERNAL database; `<engine>` is `postgres`/`mysql`/`mssql`/`oracle`/`snowflake`. `connectionString` is a `Value` — reach for `env(...)`, not a literal — stored as `context.connection_string_flex`. A bare string stores the older `context.connection_string` instead (an env-var name unless it looks like a URL); each form round-trips as itself.
58
60
  - `s.db.transaction({ body, as? })` — run a `Statement[]` atomically. `as` binds whatever the block returned.
59
61
  - `s.db.bulk.add({ table, items, allowIdField?, as? })` / `s.db.bulk.update` / `s.db.bulk.patch` — `items` is an array `Value`.
package/llms/values.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - `c.array(a: Json[]) => Value` — Array constant (JSON string) → tag "const:array". Plain JSON literals only — a nested tagged value is rejected, same as c.obj.
13
13
  - `c.expression(source: string) => Value` — Xano Expression Engine source, passed through VERBATIM → tag "const:expr2". The string IS the expression: c.expression('"Hi, " ~ $input.name'), c.expression("$var.price * $var.qty"). ⚠️ NOT VALIDATED — never parsed or type-checked, invisible to InferResponse, and untouched by a rename that updates every typed ref(); a typo surfaces at runtime or as a wrong answer. Use it ONLY for syntax the typed surfaces cannot express (~ concatenation, inline arithmetic, conditionals) — prefer ref/inp/col, withFilters+fl.*, and obj() (which BUILDS a checked expression). Not the expr() condition builder.
14
14
  - `c.now() => Value` — Current time as epoch-ms — the engine-native const:epochms constant (no filter). Valid inline as a where/cmp operand. For cutoff math (cutoff = now - max_age) either compare inline or, for reuse/readability, hoist it into an s.set_var and compare against the var.
15
- - `obj(fields: Record<string, Value | nested>) => ObjValue<typeof fields>` — Dynamic object value → tag "const:expr2" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/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. 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. Still rejected: a filter ARGUMENT carrying its own chain (a trailing | binds to the whole value, not one argument), 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.
15
+ - `obj(fields: Record<string, Value | nested>) => ObjValue<typeof fields>` — Dynamic object value → tag "const:expr2" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/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([...])` — reach for the bare form, it is shorter. 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 carrying its own chain (a trailing | binds to the whole value, not one argument), 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.
16
16
  - `ref(name: string, opts?: { safe?: boolean }) => Value` — Reference a stack variable → tag "var". Pass { safe: true } for null-safe nested access — a dotted ref("owner.user_id", { safe: true }) compiles through the get filter so it resolves to null instead of raising "Unable to locate var" when the base is null.
17
17
  - `inp(name: string) => Value` — Reference a function/endpoint input → tag "input". Resolves ONLY against the `input` block of the def it sits in — a value produced earlier in the stack is `ref("var.field")`, not `inp("field")`. A name that is not declared here deploys clean and fails at runtime with ERROR_FATAL "Unable to locate input: <name>" on every branch that reads it; `export()` warns, and `--strict` fails the build. Sending the name in the request does NOT rescue it — an undeclared input is never bound, so the call fails identically with the value present. A dotted path drills INTO a declared input (`inp("action.amount")` needs a declared `action`).
18
18
  - `col(name: string) => Value` — Reference a table column → tag "col".
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # xanots v0.0.10
1
+ # xanots v0.0.11
2
2
 
3
3
  > TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
4
4
 
@@ -23,7 +23,9 @@ every gotcha, and control flow. Per-surface detail lives in the topic files list
23
23
  below — open the one whose condition matches the task, and skip the rest. For
24
24
  exhaustive per-entry detail in NEITHER — a statement's full field schema with engine
25
25
  defaults, a filter's complete argument list, the engine `storedName` mapping — do a
26
- TARGETED lookup in the shipped `manifest.json` (grep or `jq` the one entry you need;
26
+ TARGETED lookup in the shipped `manifest.json` (a program imports it as
27
+ `@xanots/sdk/manifest.json`, which Node ESM needs `with { type: "json" }` on;
28
+ grep or `jq` the one entry you need;
27
29
  it is ~65k tokens, so never read it whole). Its top-level keys are `name`, `version`, `description`, `coverage`, `values`, `objectKinds`, `fieldTypes`, `statements`, `filters`, `cli`, `cliGlobalFlags`. `statements` and `filters` are ARRAYS, not maps — SELECT, do not index:
28
30
  jq '.statements[] | select(.sPath=="db.get")' manifest.json
29
31
  jq '.filters[] | select(.name=="json_decode")' manifest.json
@@ -266,6 +268,10 @@ Non-obvious authoring rules:
266
268
  check-in — use `db.query({ where: [expr(col("habit"), "=", ...), expr(col("date"), "=", ...)], as })`
267
269
  (a `where` array is ANDed) and branch on the result, rather than pushing the
268
270
  check to the client.
271
+ - **A column named `run` is reserved.** The table deploys and reads back fine, then
272
+ EVERY `s.db.add` into it 400s — at any column type, with or without a value — and
273
+ the error names the column while complaining about the VALUE. Use `run_id`. Exact,
274
+ case-sensitive, one name: `Run`/`runs`/`run_id` are fine. `--strict` fails on it.
269
275
  - **System columns are auto-injected.** `id` + `created_at` are prepended to
270
276
  every table (`system: true` by default); declaring them by hand is redundant.
271
277
  `id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.
@@ -320,18 +326,19 @@ Non-obvious authoring rules:
320
326
  `xanots export`/`deploy` CLI path. Seed rows are never emitted into the bundle
321
327
  either way; only `deploy` ships them. The `node:fs` writers
322
328
  (`writeBundle`/`writeArtifact`) and lock-file I/O import from `@xanots/sdk/node`,
323
- NOT the browser-safe `@xanots/sdk` entry (which a frontend can import query
324
- defs from to use `getPath()`/`InferInput` with no Node built-ins in the bundle).
329
+ NOT the browser-safe `@xanots/sdk` entry (a frontend imports defs from it for
330
+ `getPath()`/`InferInput`).
325
331
  The compiler machinery (per-kind `encode*`, the registries, the bundle serializer,
326
332
  the lock model) is on `@xanots/sdk/internal` and is never needed to author.
333
+ READING a bundle back is `@xanots/sdk/bundle` — a statement walker (`2.if.0` paths),
334
+ a structural hash, `mvp:*` catalog, `tableRefOf`.
327
335
  - **Client bundle size / tree-shaking.** `@xanots/sdk` is `sideEffects: false` and pulls
328
336
  no Node built-ins, so a bundler drops unused SDK exports. But importing a **def** for its
329
337
  `getPath()`/`verb`/`getUrl()`/`getChannel()` also pulls whatever its `stack` references:
330
338
  the `s.*`/`c.*` factory CALLS run at module load to BUILD it. Types are free.
331
339
  ⚠ A FLOOR — **~289 kB minified (~57 kB gzipped)** for the FIRST def; splitting modules
332
340
  never removes it. The floor is the RUNTIME, not the def: a second def, or a much richer
333
- one, adds ~1 kB. So the cost is paid by importing ANY def at all, and reducing what a
334
- def does will not reduce it.
341
+ one, adds ~1 kB so reducing what a def does will not reduce it.
335
342
  Fix: `xanots routes <entry> --emit xano/routes.gen.ts` (`paths` is an accepted alias) — verbs, paths, and sockets as
336
343
  plain data importing NOTHING, still compile-checked: `routePath("blog/{slug}", { slug })`,
337
344
  `channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
@@ -385,17 +392,18 @@ Non-obvious authoring rules:
385
392
  return.
386
393
  - **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**
387
394
  The regex filters (`regex_test`/`regex_match`/`regex_replace`/…) are pattern-piped
388
- PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped. A bare
389
- `c.text("^[^@\s]+@...$")` is an invalid pattern that matches *nothing* for every
390
- input, so a precondition on it silently rejects all values (valid ones included).
391
- `c.regex("^[^@\s]+@[^@\s]+\.[^@\s]+$", "i")` wraps + escapes it for you (accepts a
392
- JS `RegExp` too: `c.regex(/^…$/i)`), and `withFilters` throws on a bare `c.text`
393
- pattern pointing here from ANY position in the chain, so a normalizer in front
394
- (`withFilters(c.text("^hi"), fl.trim(), fl.regex_test(...))`) is refused too; nothing
395
- upstream adds the delimiters. `s.expect.to_match`'s `value` is the same PATTERN slot,
396
- refused the same way; a `ref`/`inp` pattern is passed through untouched, either place.
397
- Better still, prefer a native typed input
398
- (`input.email({...})`) over hand-rolled validation.
395
+ PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped, and the
396
+ ARGUMENT is the subject. A bare `c.text("^…$")` is an invalid pattern that matches
397
+ *nothing* for every input, so a precondition on it silently rejects all values.
398
+ `c.regex(body, "i")` wraps + escapes it for you (a JS `RegExp` too: `c.regex(/^…$/i)`).
399
+ Reversed subject piped, pattern in the argument reads correctly, type-checks, and
400
+ answers false for EVERY input, so an `if (matches) reject` guard admits what it
401
+ refuses. `withFilters` throws on a bare `c.text` pattern from ANY position in
402
+ the chain (a normalizer in front of the regex filter is refused too; nothing upstream
403
+ adds the delimiters) and on a pattern found in the subject slot; `s.expect.to_match`
404
+ is the same PATTERN slot, refused both ways; a `ref`/`inp` pattern is passed through untouched.
405
+ `export()` warns on a reversed pair in stored bytes. Better still:
406
+ a native typed input (`input.email`) over hand-rolled validation.
399
407
  - **Compose a rule set as SIBLINGS, not a folded chain.** `and(...rules)` takes any
400
408
  number of terms and encodes flat; `rules.reduce((acc, r) => and(acc, r))` nests one
401
409
  container per rule, which costs quadratic bytes (512 terms: 394 KiB flat, 21 MiB
@@ -522,7 +530,7 @@ to survive a rename).
522
530
  - `verb`: `"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"` (required), UPPERCASE. Anything else — most often a lowercase `"post"` — makes `query()` THROW, because Xano does NOT reject it: it stores the verb as NULL, a null verb serves as GET, and the endpoint then answers on the wrong method while the one you meant 404s `Unable to locate request.`
523
531
  - `apiGroup`: an `apiGroup()` def handle (or its name) — binds by guid, stable across syncs. Raw numeric `apiGroupId?` is the escape hatch and wins if both given.
524
532
  - `auth`: `false` (no auth) or an auth-table id; `responseType`: `"standard" | "stream"` (default `standard`) — any other spelling THROWS, since Xano stores an unrecognized one as NULL and a null buffers as `standard`, so a misspelled stream quietly does not stream.
525
- - `name` is the endpoint PATH within the group. A `{param}` segment is a URL PATH PARAM bound to the input of the same name, and segments chain: `name: "blog/{slug}/review/{review_id}"` + `input: { slug: input.text(), review_id: input.int() }`. Read it with `inp("slug")` like any other input. Every `{param}` MUST have a matching input or `query()` THROWS — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. A `{param}` need NOT be a whole segment (`"blog/post-{slug}"` routes fine), but its type must fit one segment (no object/list/json/file/geo/vector); there are no wildcards or patterns. `required: true` is NOT demanded (the engine's editor leaves path inputs unmarked). Inputs absent from the path are ordinary query-string/body params. Name charset is ONLY `A-Za-z0-9_-/{}`, max 200: a `.` (`"export.zip"`) is NOT rejected by Xano — it stores an EMPTY name that deploys clean then 404s forever, so `query()` THROWS. Use `"export_zip"` and set the extension in the response headers.
533
+ - `name` is the endpoint PATH within the group. A `{param}` segment is a URL PATH PARAM bound to the input of the same name, and segments chain: `name: "blog/{slug}/review/{review_id}"` + `input: { slug: input.text(), review_id: input.int() }`. Read it with `inp("slug")` like any other input. Every `{param}` MUST have a matching input or `query()` THROWS — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. A `{param}` need NOT be a whole segment (`"blog/post-{slug}"` routes fine), but its type must fit one segment (no object/list/json/file/geo/vector); there are no wildcards or patterns. `required: true` is NOT demanded (the engine's editor leaves path inputs unmarked). The CONVERSE is warned, not enforced: an input a `GET`/`DELETE`/`HEAD` looks ONE ROW up by (`s.db.get`/`get_by_id`/`has`/by-field edit/patch/delete) belongs in the path — `export()` warns `query.path-segment-candidate`. It still serves `?blog_id=1`, but the route is not addressable and `getPath()` types STATIC, so a caller cannot pass the value positionally. A segment is any value naming WHICH resource is wanted, not just an id (`"shop/{country}"`). An input that NARROWS A LIST (`s.db.query`) stays a query-string param. Inputs absent from the path are ordinary query-string/body params. Name charset is ONLY `A-Za-z0-9_-/{}`, max 200: a `.` (`"export.zip"`) is NOT rejected by Xano — it stores an EMPTY name that deploys clean then 404s forever, so `query()` THROWS. Use `"export_zip"` and set the extension in the response headers.
526
534
  - **Client recipe:** `q.getPath({ params: { slug: "hello" } })` → `/api:<canonical>/blog/hello` — never interpolate by hand. `getPath` percent-encodes each value (so `?`/`#`/spaces stay in their segment) and throws on what encoding cannot contain: a `/`, and a value that IS `.`/`..` (a URL parser drops those before routing — `%2e` counts — addressing a different endpoint). The keys are typed from the literal `name`, so a typo is a compile error. The HANDLE's `q.toSearchParams(input)` drops path params for a GET; the free `query.toSearchParams(input)` has no view of the route and keeps every key.
527
535
  - `apiGroup({ name, guid?, canonical?, description?, docs?, swagger?, apiGroupEnabled?, documentation?, cors? })` — a query container; register it and bind queries to it via their `apiGroup`.
528
536
  - `cors?`: `{ mode?, allowOrigins?: string[], allowHeaders?: string[], allowCredentials?, maxAge?, allowMethods?: { get?, post?, put?, patch?, delete?, head? } }`.
@@ -785,7 +793,7 @@ primary key `id`):
785
793
 
786
794
  - `s.db.get({ table, fieldName?, fieldValue, lock?, output?, as? })` — one row by field match; `output` restricts returned columns (and overrides column visibility — it can pull `internal` columns like a password hash).
787
795
  - `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` — get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` for an id that names no row. Both spellings are live in pulled workspaces.
788
- - ⚠ `id` is validated `>= 1`, so the `0` sentinel an optional `f.tableRef` stores fails the request with HTTP 400 `Value is less than the minimum value of 1` — it does NOT bind `null`. The throw is not scoped to the lookup: inside a `foreach` it kills the whole request, so one unset FK loses every other row's work. Read a nullable FK with the field-match form, which binds `null` on `0` and lets the loop finish: `s.db.get({ table, fieldName: "id", fieldValue: ref("row.fk"), as })`.
796
+ - ⚠ `id` is validated `>= 1`, so the `0` sentinel an optional `f.tableRef` stores fails the request with HTTP 400 `Value is less than the minimum value of 1` — it does NOT bind `null`. The throw is not scoped to the lookup: inside a `foreach` it kills the whole request, so one unset FK loses every other row's work. Read a nullable FK with the field-match form, which binds `null` on `0` and lets the loop finish: `s.db.get({ table, fieldName: "id", fieldValue: ref("row.fk"), as })`. `export()` warns when the `id` is statically a `0` — the literal `c.int(0)`, an `inp()` whose declared input default is `0`, or a `ref()` to a column declared `default: 0` — and `--strict` fails the build.
789
797
  - `s.db.has({ table, fieldName?, fieldValue, as? })` — existence test.
790
798
  - `s.db.del({ table, fieldName?, fieldValue, as? })` — delete by field match.
791
799
  - `s.db.add({ table, row?, data?, output?, as? })` — insert; `row` is a partial keyed by column.
@@ -827,6 +835,8 @@ primary key `id`):
827
835
  - `distinct` — `"auto"` (default) | `"yes"` | `"no"`, riding `context.return.<list|stream>.distinct`.
828
836
  - `s.db.truncate({ table, reset?, as? })` · `s.db.schema({ table, path, as? })`.
829
837
  - `s.db.direct_query({ sql, responseType?, args?, parser?, as? })` — `sql` is a **raw string** (not a `Value`); binds go in `args: Value[]`. `parser: "template_engine"` renders the body as a template first — how a query interpolates a column or table name a bound arg cannot carry; omit it for the default.
838
+ - Template placeholders are Twig over the request scope: `{{ $input.name }}` for an input, `{{ $var.name }}` for a stack variable. ⚠ A BARE `{{ name }}` renders as the empty string — HTTP 200, no error, a query that silently ran with a blank where the value belonged. A bound `?` arg carries a VALUE without the template at all.
839
+ - ⚠ A table's PHYSICAL name is **not stable across deploys**. A deploy is a full replace, so every table is created afresh and the id in its name moves every time — the same unchanged project redeployed three times gave one table three different names. Never store, cache, hardcode or fixture one: resolve it from `information_schema` inside the same request that uses it.
830
840
  - `s.db.external.<engine>.direct_query({ sql, connectionString, responseType?, args?, parser?, as? })` — same shape against an EXTERNAL database; `<engine>` is `postgres`/`mysql`/`mssql`/`oracle`/`snowflake`. `connectionString` is a `Value` — reach for `env(...)`, not a literal — stored as `context.connection_string_flex`. A bare string stores the older `context.connection_string` instead (an env-var name unless it looks like a URL); each form round-trips as itself.
831
841
  - `s.db.transaction({ body, as? })` — run a `Statement[]` atomically. `as` binds whatever the block returned.
832
842
  - `s.db.bulk.add({ table, items, allowIdField?, as? })` / `s.db.bulk.update` / `s.db.bulk.patch` — `items` is an array `Value`.
@@ -929,7 +939,7 @@ Microservices (the `microservice()` def and the statement that calls it):
929
939
  - `c.array(a: Json[]) => Value` — Array constant (JSON string) → tag "const:array". Plain JSON literals only — a nested tagged value is rejected, same as c.obj.
930
940
  - `c.expression(source: string) => Value` — Xano Expression Engine source, passed through VERBATIM → tag "const:expr2". The string IS the expression: c.expression('"Hi, " ~ $input.name'), c.expression("$var.price * $var.qty"). ⚠️ NOT VALIDATED — never parsed or type-checked, invisible to InferResponse, and untouched by a rename that updates every typed ref(); a typo surfaces at runtime or as a wrong answer. Use it ONLY for syntax the typed surfaces cannot express (~ concatenation, inline arithmetic, conditionals) — prefer ref/inp/col, withFilters+fl.*, and obj() (which BUILDS a checked expression). Not the expr() condition builder.
931
941
  - `c.now() => Value` — Current time as epoch-ms — the engine-native const:epochms constant (no filter). Valid inline as a where/cmp operand. For cutoff math (cutoff = now - max_age) either compare inline or, for reuse/readability, hoist it into an s.set_var and compare against the var.
932
- - `obj(fields: Record<string, Value | nested>) => ObjValue<typeof fields>` — Dynamic object value → tag "const:expr2" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/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. 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. Still rejected: a filter ARGUMENT carrying its own chain (a trailing | binds to the whole value, not one argument), 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.
942
+ - `obj(fields: Record<string, Value | nested>) => ObjValue<typeof fields>` — Dynamic object value → tag "const:expr2" (an object-literal expression string). The dynamic sibling of c.obj: members may be inp/ref/auth/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([...])` — reach for the bare form, it is shorter. 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 carrying its own chain (a trailing | binds to the whole value, not one argument), 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.
933
943
  - `ref(name: string, opts?: { safe?: boolean }) => Value` — Reference a stack variable → tag "var". Pass { safe: true } for null-safe nested access — a dotted ref("owner.user_id", { safe: true }) compiles through the get filter so it resolves to null instead of raising "Unable to locate var" when the base is null.
934
944
  - `inp(name: string) => Value` — Reference a function/endpoint input → tag "input". Resolves ONLY against the `input` block of the def it sits in — a value produced earlier in the stack is `ref("var.field")`, not `inp("field")`. A name that is not declared here deploys clean and fails at runtime with ERROR_FATAL "Unable to locate input: <name>" on every branch that reads it; `export()` warns, and `--strict` fails the build. Sending the name in the request does NOT rescue it — an undeclared input is never bound, so the call fails identically with the value present. A dotted path drills INTO a declared input (`inp("action.amount")` needs a declared `action`).
935
945
  - `col(name: string) => Value` — Reference a table column → tag "col".
@@ -1101,12 +1111,13 @@ database with a single `s.db.direct_query` UPDATE (`SET clicks = clicks + 1 WHER
1101
1111
  which the DB applies atomically. Reserve the pipeline form for low-contention counters
1102
1112
  where a rare lost update is acceptable.
1103
1113
  ⚠ `direct_query` needs the table's PHYSICAL Postgres name, which the typed surface
1104
- does NOT expose: the engine derives a physical name from workspace + table ids (of the
1105
- form `x<workspace_id>_<table_id>`, e.g. `x6_203970`), ids assigned at importnot knowable
1106
- from a `table()` def (identity is a name + guid, not the numeric id), and `sql_name`
1107
- persists empty. So the safe counter drops out of the typed surface: hardcode
1108
- the physical name after inspecting the deployed table. A typed atomic path needs an
1109
- engine change.
1114
+ does NOT expose: the engine derives it from ids assigned at import not knowable from a
1115
+ `table()` def (identity is a name + guid, not the numeric id)and `sql_name` persists
1116
+ empty. The derived name is also NOT STABLE: a deploy is a full replace, so every table is
1117
+ created afresh and the id in its name moves each time, on the same unchanged project. So
1118
+ the safe counter drops out of the typed surface: resolve the physical name from
1119
+ `information_schema` inside the request that uses it, and never store, cache or hardcode
1120
+ one. A typed atomic path needs an engine change.
1110
1121
 
1111
1122
  - `fl.add(value: decimal): decimal`
1112
1123
  - `fl.append(value: <T>, path: text): <T>[]`
@@ -1198,11 +1209,11 @@ engine change.
1198
1209
  - `fl.prepend(value: <T>, path: text): <T>[]`
1199
1210
  - `fl.range(start: int, stop: int): int[]`
1200
1211
  - `fl.reduce(initial_value: int, code: text, timeout?: int): any[]` — `code` is a JS body run per element; the ACCUMULATOR is `$result` (there is no `$acc`) and `initial_value` is REQUIRED — omitting it would slot the code as the initial value
1201
- - `fl.regex_match(subject: text): text[]`
1202
- - `fl.regex_match_all(subject: text): text[]`
1212
+ - `fl.regex_match(subject: text): text[]` — piped value is the PATTERN, the arg is the subject — see `regex_test`
1213
+ - `fl.regex_match_all(subject: text): text[]` — piped value is the PATTERN, the arg is the subject — see `regex_test`
1203
1214
  - `fl.regex_quote(delimiter?: text): text`
1204
- - `fl.regex_replace(replacement: text, subject: text): text`
1205
- - `fl.regex_test(subject: text): bool`
1215
+ - `fl.regex_replace(replacement: text, subject: text): text` — piped value is the PATTERN, `subject` is the text searched — see `regex_test`. The replacement comes FIRST
1216
+ - `fl.regex_test(subject: text): bool` — piped value is the PATTERN (build it with `c.regex(...)`); the arg is the subject — the REVERSE of `contains`/`starts_with`. Swapped, it answers false for every input with no error, so write `withFilters(c.regex("^a+$"), fl.regex_test(inp("s")))` (or name the arg: `fl.regex_test({ subject: inp("s") })`). A pattern found in the subject slot is refused at build time
1206
1217
  - `fl.round(precision?: int): decimal`
1207
1218
  - `fl.rtrim(mask?: text): text`
1208
1219
  - `fl.secureid_decode(salt: text): int`
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # xanots v0.0.10
1
+ # xanots v0.0.11
2
2
 
3
3
  > TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.
4
4
 
@@ -23,7 +23,9 @@ every gotcha, and control flow. Per-surface detail lives in the topic files list
23
23
  below — open the one whose condition matches the task, and skip the rest. For
24
24
  exhaustive per-entry detail in NEITHER — a statement's full field schema with engine
25
25
  defaults, a filter's complete argument list, the engine `storedName` mapping — do a
26
- TARGETED lookup in the shipped `manifest.json` (grep or `jq` the one entry you need;
26
+ TARGETED lookup in the shipped `manifest.json` (a program imports it as
27
+ `@xanots/sdk/manifest.json`, which Node ESM needs `with { type: "json" }` on;
28
+ grep or `jq` the one entry you need;
27
29
  it is ~65k tokens, so never read it whole). Its top-level keys are `name`, `version`, `description`, `coverage`, `values`, `objectKinds`, `fieldTypes`, `statements`, `filters`, `cli`, `cliGlobalFlags`. `statements` and `filters` are ARRAYS, not maps — SELECT, do not index:
28
30
  jq '.statements[] | select(.sPath=="db.get")' manifest.json
29
31
  jq '.filters[] | select(.name=="json_decode")' manifest.json
@@ -266,6 +268,10 @@ Non-obvious authoring rules:
266
268
  check-in — use `db.query({ where: [expr(col("habit"), "=", ...), expr(col("date"), "=", ...)], as })`
267
269
  (a `where` array is ANDed) and branch on the result, rather than pushing the
268
270
  check to the client.
271
+ - **A column named `run` is reserved.** The table deploys and reads back fine, then
272
+ EVERY `s.db.add` into it 400s — at any column type, with or without a value — and
273
+ the error names the column while complaining about the VALUE. Use `run_id`. Exact,
274
+ case-sensitive, one name: `Run`/`runs`/`run_id` are fine. `--strict` fails on it.
269
275
  - **System columns are auto-injected.** `id` + `created_at` are prepended to
270
276
  every table (`system: true` by default); declaring them by hand is redundant.
271
277
  `id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.
@@ -320,18 +326,19 @@ Non-obvious authoring rules:
320
326
  `xanots export`/`deploy` CLI path. Seed rows are never emitted into the bundle
321
327
  either way; only `deploy` ships them. The `node:fs` writers
322
328
  (`writeBundle`/`writeArtifact`) and lock-file I/O import from `@xanots/sdk/node`,
323
- NOT the browser-safe `@xanots/sdk` entry (which a frontend can import query
324
- defs from to use `getPath()`/`InferInput` with no Node built-ins in the bundle).
329
+ NOT the browser-safe `@xanots/sdk` entry (a frontend imports defs from it for
330
+ `getPath()`/`InferInput`).
325
331
  The compiler machinery (per-kind `encode*`, the registries, the bundle serializer,
326
332
  the lock model) is on `@xanots/sdk/internal` and is never needed to author.
333
+ READING a bundle back is `@xanots/sdk/bundle` — a statement walker (`2.if.0` paths),
334
+ a structural hash, `mvp:*` catalog, `tableRefOf`.
327
335
  - **Client bundle size / tree-shaking.** `@xanots/sdk` is `sideEffects: false` and pulls
328
336
  no Node built-ins, so a bundler drops unused SDK exports. But importing a **def** for its
329
337
  `getPath()`/`verb`/`getUrl()`/`getChannel()` also pulls whatever its `stack` references:
330
338
  the `s.*`/`c.*` factory CALLS run at module load to BUILD it. Types are free.
331
339
  ⚠ A FLOOR — **~289 kB minified (~57 kB gzipped)** for the FIRST def; splitting modules
332
340
  never removes it. The floor is the RUNTIME, not the def: a second def, or a much richer
333
- one, adds ~1 kB. So the cost is paid by importing ANY def at all, and reducing what a
334
- def does will not reduce it.
341
+ one, adds ~1 kB so reducing what a def does will not reduce it.
335
342
  Fix: `xanots routes <entry> --emit xano/routes.gen.ts` (`paths` is an accepted alias) — verbs, paths, and sockets as
336
343
  plain data importing NOTHING, still compile-checked: `routePath("blog/{slug}", { slug })`,
337
344
  `channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base
@@ -385,17 +392,18 @@ Non-obvious authoring rules:
385
392
  return.
386
393
  - **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**
387
394
  The regex filters (`regex_test`/`regex_match`/`regex_replace`/…) are pattern-piped
388
- PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped. A bare
389
- `c.text("^[^@\s]+@...$")` is an invalid pattern that matches *nothing* for every
390
- input, so a precondition on it silently rejects all values (valid ones included).
391
- `c.regex("^[^@\s]+@[^@\s]+\.[^@\s]+$", "i")` wraps + escapes it for you (accepts a
392
- JS `RegExp` too: `c.regex(/^…$/i)`), and `withFilters` throws on a bare `c.text`
393
- pattern pointing here from ANY position in the chain, so a normalizer in front
394
- (`withFilters(c.text("^hi"), fl.trim(), fl.regex_test(...))`) is refused too; nothing
395
- upstream adds the delimiters. `s.expect.to_match`'s `value` is the same PATTERN slot,
396
- refused the same way; a `ref`/`inp` pattern is passed through untouched, either place.
397
- Better still, prefer a native typed input
398
- (`input.email({...})`) over hand-rolled validation.
395
+ PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped, and the
396
+ ARGUMENT is the subject. A bare `c.text("^…$")` is an invalid pattern that matches
397
+ *nothing* for every input, so a precondition on it silently rejects all values.
398
+ `c.regex(body, "i")` wraps + escapes it for you (a JS `RegExp` too: `c.regex(/^…$/i)`).
399
+ Reversed subject piped, pattern in the argument reads correctly, type-checks, and
400
+ answers false for EVERY input, so an `if (matches) reject` guard admits what it
401
+ refuses. `withFilters` throws on a bare `c.text` pattern from ANY position in
402
+ the chain (a normalizer in front of the regex filter is refused too; nothing upstream
403
+ adds the delimiters) and on a pattern found in the subject slot; `s.expect.to_match`
404
+ is the same PATTERN slot, refused both ways; a `ref`/`inp` pattern is passed through untouched.
405
+ `export()` warns on a reversed pair in stored bytes. Better still:
406
+ a native typed input (`input.email`) over hand-rolled validation.
399
407
  - **Compose a rule set as SIBLINGS, not a folded chain.** `and(...rules)` takes any
400
408
  number of terms and encodes flat; `rules.reduce((acc, r) => and(acc, r))` nests one
401
409
  container per rule, which costs quadratic bytes (512 terms: 394 KiB flat, 21 MiB
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xanots",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.",
5
5
  "coverage": {
6
6
  "objectKinds": {
@@ -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. 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. Still rejected: a filter ARGUMENT carrying its own chain (a trailing | binds to the whole value, not one argument), 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\"`. 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([...])` — reach for the bare form, it is shorter. 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 carrying its own chain (a trailing | binds to the whole value, not one argument), 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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xanots/sdk",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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",
@@ -52,7 +52,12 @@
52
52
  "types": "./dist/internal.d.ts",
53
53
  "import": "./dist/internal.js"
54
54
  },
55
- "./package.json": "./package.json"
55
+ "./bundle": {
56
+ "types": "./dist/bundle.d.ts",
57
+ "import": "./dist/bundle.js"
58
+ },
59
+ "./package.json": "./package.json",
60
+ "./manifest.json": "./manifest.json"
56
61
  },
57
62
  "files": [
58
63
  "dist",
@@ -1,11 +0,0 @@
1
- import {
2
- readLockFile,
3
- writeLockFile
4
- } from "./chunk-OYMR5AMJ.js";
5
- import "./chunk-GNPVYOPB.js";
6
- import "./chunk-7JDT4PBU.js";
7
- export {
8
- readLockFile,
9
- writeLockFile
10
- };
11
- //# sourceMappingURL=io-M7XZEMK7.js.map