@xanots/sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/dist/agent-file-refresh-7XCACU34.js +95 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +184 -0
- package/dist/capture-HUV5BNTC.js +32 -0
- package/dist/chunk-26CNBZE2.js +963 -0
- package/dist/chunk-33ND6LKO.js +78 -0
- package/dist/chunk-3DUAGYGP.js +1550 -0
- package/dist/chunk-3IGNIP6R.js +89 -0
- package/dist/chunk-47WDWMBJ.js +14 -0
- package/dist/chunk-4BXJGVZ3.js +318 -0
- package/dist/chunk-4HT3BNZ7.js +675 -0
- package/dist/chunk-4YMD2OOZ.js +23 -0
- package/dist/chunk-5WGEURVI.js +156 -0
- package/dist/chunk-5YTBTF25.js +29 -0
- package/dist/chunk-6DHBYBTO.js +166 -0
- package/dist/chunk-76QBEIGO.js +66 -0
- package/dist/chunk-7DKX2SPN.js +100 -0
- package/dist/chunk-7REDODS2.js +468 -0
- package/dist/chunk-C56BC2FY.js +254 -0
- package/dist/chunk-DGSF2Q5H.js +60 -0
- package/dist/chunk-EMCAWMHC.js +3982 -0
- package/dist/chunk-EZG76F7R.js +159 -0
- package/dist/chunk-FJ6ZS5KB.js +3607 -0
- package/dist/chunk-GNPVYOPB.js +17 -0
- package/dist/chunk-HJPTWBLH.js +38 -0
- package/dist/chunk-IZW6MK3U.js +248 -0
- package/dist/chunk-LH6Z6KFV.js +247 -0
- package/dist/chunk-MZUI3JNX.js +93 -0
- package/dist/chunk-NDZFBZHC.js +167 -0
- package/dist/chunk-NOO35BQ2.js +37 -0
- package/dist/chunk-NUQCEOKA.js +13 -0
- package/dist/chunk-QTNO2WD6.js +56 -0
- package/dist/chunk-RVB7ITKN.js +49 -0
- package/dist/chunk-TRU5OHQ5.js +6904 -0
- package/dist/chunk-VKFQJEMW.js +814 -0
- package/dist/chunk-VTIL47DT.js +125 -0
- package/dist/chunk-WHOJWOSV.js +112 -0
- package/dist/chunk-WKP4D5MO.js +1692 -0
- package/dist/chunk-X4DVXBFY.js +38 -0
- package/dist/chunk-XQ22GLYS.js +118 -0
- package/dist/chunk-YDZYMZBE.js +670 -0
- package/dist/chunk-YGPIY3WT.js +1146 -0
- package/dist/chunk-YYRDLFSJ.js +602 -0
- package/dist/chunk-ZUTSMMAG.js +46 -0
- package/dist/cli.d.ts +331 -0
- package/dist/cli.js +33 -0
- package/dist/codegen-command-OOKK6AZK.js +40 -0
- package/dist/codegen.d.ts +114 -0
- package/dist/codegen.js +71 -0
- package/dist/completion-TIPPZN7H.js +298 -0
- package/dist/config-NL33PN4D.js +84 -0
- package/dist/deploy-command-4EQ7XX5G.js +451 -0
- package/dist/env-target-IFJ5QDHC.js +16 -0
- package/dist/ephemeral-command-3X3I4ULC.js +242 -0
- package/dist/index.d.ts +4477 -0
- package/dist/index.js +300 -0
- package/dist/init-command-R6H7ZXW5.js +27 -0
- package/dist/internal.d.ts +74 -0
- package/dist/internal.js +2065 -0
- package/dist/io-AMIKRLPC.js +11 -0
- package/dist/live-diff-Y3AKETHE.js +196 -0
- package/dist/lock-3CVKALKT.js +37 -0
- package/dist/lock-commands-XTR2YKLY.js +232 -0
- package/dist/login-command-ACJF6KWQ.js +167 -0
- package/dist/logout-command-MX3MJS5U.js +68 -0
- package/dist/loop-OM3P2Z6O.js +93 -0
- package/dist/marketplace-command-RATXTKXN.js +379 -0
- package/dist/meta-client-OW5WKWW7.js +170 -0
- package/dist/node.d.ts +59 -0
- package/dist/node.js +442 -0
- package/dist/profile-command-SWJ3SPKR.js +77 -0
- package/dist/release-command-VV5UVZKL.js +427 -0
- package/dist/response-CVAE2kMj.d.ts +1915 -0
- package/dist/routes-manifest-SP3ZXLMR.js +244 -0
- package/dist/runtime-V4C3AC3A.js +13 -0
- package/dist/sandbox-details-command-HJE5SPVG.js +18 -0
- package/dist/sandbox-export-command-QCJY4GMV.js +24 -0
- package/dist/static-host-D6KS7X45.js +138 -0
- package/dist/store-BQ97nKIF.d.ts +7760 -0
- package/dist/test-command-TSPBQHBM.js +383 -0
- package/dist/validate-command-4ERHJSCO.js +122 -0
- package/dist/verify-rollout-FAVQ6EJY.js +40 -0
- package/dist/workspace-K72NP7SX.js +49 -0
- package/dist/workspace-command-OF23DPNI.js +156 -0
- package/dist/workspace-export-AJMGN3CQ.js +10 -0
- package/llms/fields.md +104 -0
- package/llms/filters.md +183 -0
- package/llms/kinds-agent-mcp.md +11 -0
- package/llms/kinds-core.md +68 -0
- package/llms/kinds-realtime.md +76 -0
- package/llms/lambda.md +111 -0
- package/llms/legacy.md +43 -0
- package/llms/object-kinds.md +31 -0
- package/llms/statements-calls.md +23 -0
- package/llms/statements-catalog.md +309 -0
- package/llms/statements-data.md +58 -0
- package/llms/statements-runtime.md +48 -0
- package/llms/tests.md +58 -0
- package/llms/triggers.md +22 -0
- package/llms/values.md +29 -0
- package/llms-full.txt +1672 -0
- package/llms.txt +483 -0
- package/manifest.json +9592 -0
- package/package.json +110 -0
package/dist/internal.js
ADDED
|
@@ -0,0 +1,2065 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FIELD_METHODS,
|
|
3
|
+
IMPLEMENTED_STATEMENTS,
|
|
4
|
+
STATEMENT_SURFACES,
|
|
5
|
+
SUPERSEDED_STATEMENTS,
|
|
6
|
+
TOTAL_STATEMENTS,
|
|
7
|
+
sPathOf
|
|
8
|
+
} from "./chunk-4BXJGVZ3.js";
|
|
9
|
+
import {
|
|
10
|
+
addonKind,
|
|
11
|
+
agentKind,
|
|
12
|
+
apiGroupKind,
|
|
13
|
+
encodeAddon,
|
|
14
|
+
encodeAgent,
|
|
15
|
+
encodeApiGroup,
|
|
16
|
+
encodeMcpServer,
|
|
17
|
+
encodeMiddleware,
|
|
18
|
+
encodeRealtimeMessage,
|
|
19
|
+
encodeRealtimeServer,
|
|
20
|
+
encodeSchedule,
|
|
21
|
+
encodeTask,
|
|
22
|
+
encodeTool,
|
|
23
|
+
encodeToolRefs,
|
|
24
|
+
encodeToolsetBase,
|
|
25
|
+
encodeWorkflowTest,
|
|
26
|
+
mcpServerKind,
|
|
27
|
+
middlewareKind,
|
|
28
|
+
realtimeMessageGuid,
|
|
29
|
+
realtimeMessageKind,
|
|
30
|
+
realtimeServerKind,
|
|
31
|
+
resolveRealtimeServerCanonical,
|
|
32
|
+
resolveToolsetCanonical,
|
|
33
|
+
taskKind,
|
|
34
|
+
toolKind,
|
|
35
|
+
workflowTestKind
|
|
36
|
+
} from "./chunk-YDZYMZBE.js";
|
|
37
|
+
import {
|
|
38
|
+
GENERATED_SPECS,
|
|
39
|
+
GENERATED_STATEMENT_NAMES,
|
|
40
|
+
HEADER_DUPLICATES,
|
|
41
|
+
PRECONDITION_ERROR_STATUS,
|
|
42
|
+
PRECONDITION_ERROR_TYPES,
|
|
43
|
+
STORAGE_ACCESS,
|
|
44
|
+
channelPathParams,
|
|
45
|
+
declaredServicePorts,
|
|
46
|
+
encodeFromSpec,
|
|
47
|
+
encodeMicroservice,
|
|
48
|
+
encodeRealtimeChannel,
|
|
49
|
+
encodeTrigger,
|
|
50
|
+
encodeWorkspaceConfig,
|
|
51
|
+
generated,
|
|
52
|
+
microserviceKind,
|
|
53
|
+
realtimeChannelGuid,
|
|
54
|
+
realtimeChannelKind,
|
|
55
|
+
registerSpec,
|
|
56
|
+
triggerKind,
|
|
57
|
+
workspaceKind
|
|
58
|
+
} from "./chunk-FJ6ZS5KB.js";
|
|
59
|
+
import {
|
|
60
|
+
COMMANDS,
|
|
61
|
+
FLAGS,
|
|
62
|
+
GLOBAL_FLAGS,
|
|
63
|
+
flagKey,
|
|
64
|
+
flagSummary
|
|
65
|
+
} from "./chunk-YYRDLFSJ.js";
|
|
66
|
+
import {
|
|
67
|
+
DECODE_ONLY_STATEMENTS,
|
|
68
|
+
FILTER_NAMES,
|
|
69
|
+
FILTER_SPECS,
|
|
70
|
+
LAMBDA_BINDINGS,
|
|
71
|
+
LAMBDA_GLOBALS,
|
|
72
|
+
LAMBDA_MODULE_GLOBALS,
|
|
73
|
+
PAYLOAD_ARRAY_KEYS,
|
|
74
|
+
QUERY_EXPRESSION_FILTERS,
|
|
75
|
+
TAGS,
|
|
76
|
+
VECTOR_FILTERS,
|
|
77
|
+
buildBundle,
|
|
78
|
+
buildMiddlewareBlock,
|
|
79
|
+
buildWorkspaceHistory,
|
|
80
|
+
calcSignatureJson,
|
|
81
|
+
encodeColumn,
|
|
82
|
+
encodeContainerHistory,
|
|
83
|
+
encodeFunction,
|
|
84
|
+
encodeHistory,
|
|
85
|
+
encodeIndex,
|
|
86
|
+
encodeMiddlewareEntry,
|
|
87
|
+
encodeMiddlewareList,
|
|
88
|
+
encodeObject,
|
|
89
|
+
encodeQuery,
|
|
90
|
+
encodeResponse,
|
|
91
|
+
encodeTable,
|
|
92
|
+
encodeView,
|
|
93
|
+
functionKind,
|
|
94
|
+
getKind,
|
|
95
|
+
isQueryExpressionFilter,
|
|
96
|
+
isRegisteredKind,
|
|
97
|
+
phpJsonEncode,
|
|
98
|
+
queryKind,
|
|
99
|
+
registerKind,
|
|
100
|
+
registeredKinds,
|
|
101
|
+
resolveAuthRef,
|
|
102
|
+
serializeBundle,
|
|
103
|
+
tableKind,
|
|
104
|
+
toNestedFields
|
|
105
|
+
} from "./chunk-EMCAWMHC.js";
|
|
106
|
+
import "./chunk-WHOJWOSV.js";
|
|
107
|
+
import {
|
|
108
|
+
encodeStatement,
|
|
109
|
+
getStatementFactory,
|
|
110
|
+
isRegisteredStatement
|
|
111
|
+
} from "./chunk-LH6Z6KFV.js";
|
|
112
|
+
import {
|
|
113
|
+
LOCK_VERSION,
|
|
114
|
+
REFERENCEABLE_KINDS,
|
|
115
|
+
WORKSPACE_KEY,
|
|
116
|
+
adoptFromBundle,
|
|
117
|
+
createLockContext,
|
|
118
|
+
deriveGuid,
|
|
119
|
+
emptyLock,
|
|
120
|
+
getLockedCanonical,
|
|
121
|
+
getLockedGuid,
|
|
122
|
+
isLockSeeded,
|
|
123
|
+
lockKey,
|
|
124
|
+
mergeObserved,
|
|
125
|
+
mintCanonical,
|
|
126
|
+
parseLock,
|
|
127
|
+
recordObserved,
|
|
128
|
+
renameLockEntry,
|
|
129
|
+
resolvePayloadKey,
|
|
130
|
+
resolveRef,
|
|
131
|
+
serializeLock,
|
|
132
|
+
validateLockModel
|
|
133
|
+
} from "./chunk-4HT3BNZ7.js";
|
|
134
|
+
|
|
135
|
+
// src/manifest/manifest.ts
|
|
136
|
+
function argGrammar(args) {
|
|
137
|
+
if (args === void 0 || args.length === 0) return void 0;
|
|
138
|
+
return args.map((a) => a.required ? `<${a.name}>` : `[${a.name}]`).join(" ");
|
|
139
|
+
}
|
|
140
|
+
function flagsOf(refs) {
|
|
141
|
+
if (refs === void 0 || refs.length === 0) return void 0;
|
|
142
|
+
return refs.map((ref) => ({ flag: FLAGS[flagKey(ref)].spec, description: flagSummary(ref) }));
|
|
143
|
+
}
|
|
144
|
+
function buildCliGlobalFlags() {
|
|
145
|
+
return GLOBAL_FLAGS.map((key) => ({
|
|
146
|
+
flag: FLAGS[key].spec,
|
|
147
|
+
description: FLAGS[key].summary
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
function buildCli() {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const [name, spec] of Object.entries(COMMANDS)) {
|
|
153
|
+
if (spec.removed !== void 0) continue;
|
|
154
|
+
const description = spec.aliasOf === void 0 ? spec.summary : `${spec.summary} (alias of \`${spec.aliasOf}\`)`;
|
|
155
|
+
const subs = Object.entries(spec.subcommands ?? {});
|
|
156
|
+
if (subs.length === 0) {
|
|
157
|
+
out.push({
|
|
158
|
+
command: name,
|
|
159
|
+
...argGrammar(spec.args) !== void 0 ? { args: argGrammar(spec.args) } : {},
|
|
160
|
+
...flagsOf(spec.flags) !== void 0 ? { flags: flagsOf(spec.flags) } : {},
|
|
161
|
+
description
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const [subName, sub] of subs) {
|
|
166
|
+
if (sub.removed !== void 0) continue;
|
|
167
|
+
const args = argGrammar(sub.args ?? spec.args);
|
|
168
|
+
const flags = flagsOf(sub.flags ?? spec.flags);
|
|
169
|
+
out.push({
|
|
170
|
+
command: `${name} ${subName}`,
|
|
171
|
+
...args !== void 0 ? { args } : {},
|
|
172
|
+
...flags !== void 0 ? { flags } : {},
|
|
173
|
+
description: sub.summary
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out.sort((a, b) => a.command.localeCompare(b.command));
|
|
178
|
+
}
|
|
179
|
+
var KIND_DESCRIPTORS = [
|
|
180
|
+
{ kind: "function", payloadKey: "function", authorFactory: "defineFunction", description: "Reusable server-side logic (a custom function) callable from any stack via `s.function.run`.", registerMethod: "registerFunctions" },
|
|
181
|
+
{ kind: "table", payloadKey: "dbo", authorFactory: "table", description: "A database table: typed columns (`f.*`), indexes, and views; the schema other kinds read and write.", registerMethod: "registerTables" },
|
|
182
|
+
{ kind: "query", payloadKey: "query", authorFactory: "query", description: "An HTTP API endpoint (verb + path) bound to an API group; the main request/response surface.", registerMethod: "registerQueries" },
|
|
183
|
+
{ kind: "api_group", payloadKey: "app", authorFactory: "apiGroup", description: "A container that groups queries under a shared base path, CORS, and swagger config.", registerMethod: "registerApiGroups" },
|
|
184
|
+
{
|
|
185
|
+
kind: "trigger",
|
|
186
|
+
payloadKey: "trigger",
|
|
187
|
+
authorFactory: "{tableTrigger,realtimeServerTrigger,realtimeChannelTrigger,mcpServerTrigger,agentTrigger,workspaceTrigger,errorTrigger}",
|
|
188
|
+
description: "An event-driven handler fired by a DB write, a realtime server connection or channel join/leave/deliver, an MCP/agent connection, a branch lifecycle event, or an error \u2014 inputs are implied by type and arrive on the `t` handle.",
|
|
189
|
+
registerMethod: "registerTriggers",
|
|
190
|
+
subKinds: [
|
|
191
|
+
{ authorFactory: "tableTrigger", objType: "database", description: 'Fires when rows change on a bound table (insert/update/delete/truncate). The changed row is exposed as `t.new`/`t.old`, typed to the table when a `table()` handle is bound. Config-only (no response). `search` filters rows in the DATABASE, so it uses `col("NEW.x")`/`col("OLD.x")`, not `t`; invalid with `truncate`, and insert/delete cannot read the absent side.' },
|
|
192
|
+
{ authorFactory: "realtimeTrigger", objType: "workspace_realtime_channel", legacy: true, description: "the SUPERSEDED realtime trigger, against the workspace-global realtime layer \u2014 a different object from the current `channel`, despite the similar name. For a join hook use `realtimeChannelTrigger({ actions: { join: true } })`; for message handling use a `realtimeMessage()` handler, which is the current equivalent of its `message` action (a message is an authored unit now, not a trigger action)." },
|
|
193
|
+
{ authorFactory: "realtimeServerTrigger", objType: "realtime_server", description: "Fires when a client connects to or disconnects from a realtime server; inspect the connecting client and its permissions via `t`. Bind with `realtimeServer`. Response-bearing." },
|
|
194
|
+
{ authorFactory: "realtimeChannelTrigger", objType: "channel", description: "Fires when a client joins or leaves a channel; inspect the addressed channel path and the client via `t`. Bind with a `realtimeChannel()` handle (a bare path is ambiguous across servers). Response-bearing." },
|
|
195
|
+
{ authorFactory: "mcpServerTrigger", objType: "toolset", description: "Fires when an MCP client connects to a bound MCP server; gate or annotate the exposed tools via `t.toolset`/`t.tools`. Response-bearing." },
|
|
196
|
+
{ authorFactory: "agentTrigger", objType: "toolset", description: "Fires when a client connects to a bound agent; gate or annotate its toolset via `t.toolset`/`t.tools`. Response-bearing." },
|
|
197
|
+
{ authorFactory: "workspaceTrigger", objType: "workspace", description: "Fires on branch lifecycle events (branch new/merge/live); inspect the from/to branch and action via `t`. Config-only." },
|
|
198
|
+
{ authorFactory: "errorTrigger", objType: "error", description: "Fires when an error signature is first seen, regresses, or is marked fixed; inspect the error, caller, statement, and occurrence counts via `t`. Config-only." }
|
|
199
|
+
]
|
|
200
|
+
},
|
|
201
|
+
{ kind: "tool", payloadKey: "tool", authorFactory: "tool", description: "An agent/MCP tool: a callable capability with typed inputs an AI agent can invoke.", registerMethod: "registerTools" },
|
|
202
|
+
{ kind: "mcp_server", payloadKey: "toolset", authorFactory: "mcpServer", description: "An MCP server exposing a set of tools to external MCP clients.", registerMethod: "registerMcpServers" },
|
|
203
|
+
{ kind: "agent", payloadKey: "toolset", authorFactory: "agent", description: "An AI agent: an LLM configuration plus the tools it can call. Invoke it from any stack (query/function/task/tool/trigger) with `s.ai.agent.run` \u2014 no public endpoint; the result is a rich envelope whose completion text is at `.result`.", registerMethod: "registerAgents" },
|
|
204
|
+
{ kind: "task", payloadKey: "task", authorFactory: "task", description: "A scheduled background job (cron/interval) that runs a stack on a timer.", registerMethod: "registerTasks" },
|
|
205
|
+
{ kind: "workflow_test", payloadKey: "workflow_test", authorFactory: "workflowTest", 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 \u2014 `"live"` warns at compile time.', registerMethod: "registerWorkflowTests" },
|
|
206
|
+
{ kind: "middleware", payloadKey: "middleware", authorFactory: "middleware", description: "A reusable pre/post stack attached to a query/function/task/tool/API group to run before or after its own logic.", registerMethod: "registerMiddleware" },
|
|
207
|
+
{ kind: "addon", payloadKey: "addon", authorFactory: "addon", description: "A reusable read fragment that enriches a query result by joining related table data.", registerMethod: "registerAddons" },
|
|
208
|
+
{ kind: "realtime_server", payloadKey: "realtime_server", authorFactory: "realtimeServer", description: "A realtime (websocket) server: the canonical-addressed container that owns realtime channels. Off until `enabled: true`. Returns a handle with `getUrl(baseUrl)`/`getPath()` for the client's socket URL (`wss://<host>/ws/<canonical>`).", registerMethod: "registerRealtimeServers" },
|
|
209
|
+
{ kind: "channel", payloadKey: "channel", authorFactory: "realtimeChannel", description: "A realtime channel: a joinable path on a realtime server (`rooms/{room_id}`) with typed path params, join/publish policy, a client-visible conversation transcript, and delivery semantics. Owns message handlers. Returns a handle with `getChannel(params)` for the path a client joins.", registerMethod: "registerRealtimeChannels" },
|
|
210
|
+
{ kind: "message", payloadKey: "message", authorFactory: "realtimeMessage", description: "A realtime message handler: a named message type on a channel with its own typed payload and stack \u2014 the realtime analogue of a query. Pass the `realtimeChannel()` handle as `channel` and the owning server comes with it.", registerMethod: "registerRealtimeMessages" },
|
|
211
|
+
{ kind: "microservice", payloadKey: "microservice", authorFactory: "microservice", description: "A container workload deployed alongside the workspace, called from a stack with `s.microservice.request`. Two mutually exclusive shapes via `kind`: `builtin` declares containers (image/ports/resources/env/command/args) plus optional `ingresses`, and `helm` points at a chart and its `values` \u2014 passing both throws. EARLY SURFACE, expected to change \u2014 every export of a workspace declaring one prints a notice saying so. `configs`/`volumes` are typed and `@deprecated` but NOT deployable: the engine rejects an import carrying either, so `export()` fails the build rather than letting the deploy fatal. Put a value the workload reads in a container's `env`, and storage in a container's own `volumes` (`emptyDir`/`persistent`/`config`). Container names are free-form \u2014 they need not match the microservice name, which is what a stack addresses. SECRETS RIDE ALONG \u2014 `chart.values` and `registryAuth.dockerconfigjson` are carried into the bundle, and into a pulled tree, verbatim (they must be, or a pulled microservice could not be redeployed). Both are stored strings with NO deploy-time indirection: `process.env.X` in the def resolves at EXPORT and writes the literal into the bundle, so it is not a way to keep the credential out. Either leave `registryAuth` unset (public image, or a credential attached outside this workspace) or treat the bundle and any pulled tree as secret material \u2014 keep them out of git, or rotate after. Export prints a notice per microservice carrying either field; `--strict` does not promote it. For a secret a STACK reads, the mapped surface is `workspaceConfig({ env })` + `env(\"NAME\")`.", registerMethod: "registerMicroservices" },
|
|
212
|
+
{ kind: "workspace", payloadKey: "workspace", authorFactory: "workspaceConfig", description: "Workspace-level configuration such as default middleware chains and request-history defaults per host kind.", registerMethod: "registerWorkspace" }
|
|
213
|
+
];
|
|
214
|
+
var ENGINE_OBJECT_KINDS = [
|
|
215
|
+
{ kind: "addon", authorFactory: "addon" },
|
|
216
|
+
{ kind: "agent", authorFactory: "agent" },
|
|
217
|
+
{ kind: "agent_trigger", authorFactory: "agentTrigger" },
|
|
218
|
+
{ kind: "api_group", authorFactory: "apiGroup" },
|
|
219
|
+
{ kind: "branch", authorFactory: null, absence: "instance-owned: a branch is instance state, not workspace source" },
|
|
220
|
+
{ kind: "channel", authorFactory: "realtimeChannel" },
|
|
221
|
+
{ kind: "channel_trigger", authorFactory: "realtimeChannelTrigger" },
|
|
222
|
+
{ kind: "error_trigger", authorFactory: "errorTrigger" },
|
|
223
|
+
{ kind: "function", authorFactory: "defineFunction" },
|
|
224
|
+
{ kind: "market_item", authorFactory: null, absence: "instance-owned: marketplace provenance belongs to the instance that installed it" },
|
|
225
|
+
{ kind: "mcp_server", authorFactory: "mcpServer" },
|
|
226
|
+
{ kind: "mcp_server_trigger", authorFactory: "mcpServerTrigger" },
|
|
227
|
+
{ kind: "message", authorFactory: "realtimeMessage" },
|
|
228
|
+
{ kind: "microservice", authorFactory: "microservice" },
|
|
229
|
+
{ kind: "middleware", authorFactory: "middleware" },
|
|
230
|
+
{ kind: "query", authorFactory: "query" },
|
|
231
|
+
{
|
|
232
|
+
kind: "realtime_channel",
|
|
233
|
+
authorFactory: null,
|
|
234
|
+
absence: "unmodeled: the SUPERSEDED workspace-global realtime channel. Its trigger is authorable (`realtimeTrigger`) so a legacy workspace's handlers survive a pull, but the channel object itself is not \u2014 author a `realtimeServer` + `realtimeChannel` instead"
|
|
235
|
+
},
|
|
236
|
+
{ kind: "realtime_server", authorFactory: "realtimeServer" },
|
|
237
|
+
{ kind: "realtime_server_trigger", authorFactory: "realtimeServerTrigger" },
|
|
238
|
+
{ kind: "realtime_trigger", authorFactory: "realtimeTrigger" },
|
|
239
|
+
{ kind: "run.job", authorFactory: null, absence: "unmodeled: a container job \u2014 declares a main entrypoint plus pre/post steps and its env" },
|
|
240
|
+
{ kind: "run.service", authorFactory: null, absence: "unmodeled: a long-running container service \u2014 declares a pre step and its env" },
|
|
241
|
+
{ kind: "table", authorFactory: "table" },
|
|
242
|
+
{ kind: "table_trigger", authorFactory: "tableTrigger" },
|
|
243
|
+
{
|
|
244
|
+
kind: "tablemap",
|
|
245
|
+
authorFactory: null,
|
|
246
|
+
absence: "unmodeled: a column mapping over a table, used to shape an external schema onto it"
|
|
247
|
+
},
|
|
248
|
+
{ kind: "task", authorFactory: "task" },
|
|
249
|
+
{ kind: "tool", authorFactory: "tool" },
|
|
250
|
+
{ kind: "workflow_test", authorFactory: "workflowTest" },
|
|
251
|
+
{ kind: "workspace", authorFactory: "workspaceConfig" },
|
|
252
|
+
{ kind: "workspace_trigger", authorFactory: "workspaceTrigger" }
|
|
253
|
+
];
|
|
254
|
+
var TOTAL_OBJECT_KINDS = ENGINE_OBJECT_KINDS.length;
|
|
255
|
+
var NAMESPACE_NOTES = {
|
|
256
|
+
debug: "Despite the namespace, `s.debug.stop` is a REAL control-flow terminator, not a development aid: it ends the request with an error value (`mvp:die`). It is also exported flat as `die()` \u2014 the one flat alias whose name does not mirror its `s.*` path, so there is no `s.die`. Compare `s.throw`, which raises a named error the surrounding `s.try_catch` can catch.",
|
|
257
|
+
expect: "Assertions. **Put them in a `workflowTest({...})` stack** \u2014 assert on what a `.call` bound with `as`. That is where they belong and effectively the only place to author them. They are NOT inert elsewhere, which is the part worth knowing: a failure raises and aborts whatever stack it is in, so an `s.expect.*` left in a `query`/`function`/`task` takes the request down with an HTTP 500 carrying the assertion's own message (`to_equal failed - expected value 2 does not equal 1`). Treat one outside a `workflowTest` as a mistake to remove, not as a check that quietly does nothing. Two behaviours to know when writing them: `to_throw` sees only an error carrying a MESSAGE, so a failure whose message is empty \u2014 an endpoint answering `ERROR_CODE_ACCESS_DENIED` is the common one \u2014 reports \"response is ok\" as though nothing failed, and `to_be_within` EXCLUDES both bounds \u2014 `min < expr < max` \u2014 while `s.security.random_number`'s bounds are inclusive.",
|
|
258
|
+
workflow_test: "Run another workflow test from inside one. Pass the `workflowTest()` def handle, not a name."
|
|
259
|
+
};
|
|
260
|
+
var PUBLISHED_AUTHOR_FACTORIES = new Set(
|
|
261
|
+
KIND_DESCRIPTORS.filter((d) => !d.unpublished).flatMap(
|
|
262
|
+
(d) => d.subKinds ? d.subKinds.filter((sub) => !sub.unpublished).map((sub) => sub.authorFactory) : [d.authorFactory]
|
|
263
|
+
)
|
|
264
|
+
);
|
|
265
|
+
var IMPLEMENTED_OBJECT_KINDS = ENGINE_OBJECT_KINDS.filter(
|
|
266
|
+
(k) => k.authorFactory !== null && PUBLISHED_AUTHOR_FACTORIES.has(k.authorFactory)
|
|
267
|
+
);
|
|
268
|
+
function unmodeledObjectKinds() {
|
|
269
|
+
const implemented = new Set(IMPLEMENTED_OBJECT_KINDS.map((k) => k.kind));
|
|
270
|
+
return ENGINE_OBJECT_KINDS.filter((k) => !implemented.has(k.kind)).map((k) => ({
|
|
271
|
+
kind: k.kind,
|
|
272
|
+
absence: k.absence ?? "built but withheld from the published surface"
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
var VALUE_CONSTRUCTORS = [
|
|
276
|
+
{ name: "c.text", signature: "(s: string) => Value", description: 'String constant \u2192 tag "const".' },
|
|
277
|
+
{ name: "c.int", signature: "(n: number | bigint | string) => Value", description: 'Integer constant \u2192 tag "const:int". The engine stores integers as STRINGS and has no 53-bit limit, so pass a string or bigint for anything past Number.MAX_SAFE_INTEGER \u2014 c.int("18446744073709551615") is exact where the number literal for it is already \u2026616. A number that is not a safe integer THROWS rather than encoding the rounded value.' },
|
|
278
|
+
{ name: "c.decimal", signature: "(n: number | string) => Value", description: 'Decimal constant \u2192 tag "const:decimal". Pass a string only to keep a stored spelling a number cannot reproduce (c.decimal("10.00") keeps its trailing zeros).' },
|
|
279
|
+
{ name: "c.blank", signature: '(tag: "const:<type>") => Value', description: `The editor's UNCONFIGURED value box (stored value ""), emitted by codegen for a pulled workspace \u2014 do not author it. NOT a zero or an empty collection: the engine reads "" and "0" differently, so c.blank("const:int") \u2260 c.int(0) and neither canonicalizes into the other. Constant tags except const/const:obj, whose blanks are c.text("")/c.obj(null).` },
|
|
280
|
+
{ name: "c.bool", signature: "(b: boolean) => Value", description: 'Boolean constant \u2192 tag "const:bool".' },
|
|
281
|
+
{ name: "c.null", signature: '(tag?: "const:null" | "const:obj") => Value', description: `Null constant, stored value "null". Bare c.null() is tag "const:null". c.null("const:obj") is the OBJECT-TYPED null the engine writes into a db.* statement's @meta slot \u2014 different stored bytes from c.obj(null), which is the blank object (value ""), though both evaluate to null. Codegen emits whichever spelling the workspace stored; do not swap one for the other.` },
|
|
282
|
+
{ name: "c.obj", signature: "(o?: Json | null) => Value", description: 'Object constant \u2192 tag "const:obj". A populated one stores an empty {} carrying one `set` filter per key \u2014 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."). \u26A0 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":\u2026} survives as a key) \u2014 that is the platform, not this encoding. No argument = the empty object {} \u2014 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 \u2014 a nested tagged value (inp/ref/auth/c.*) is rejected; for a computed object response use a record of values, not c.obj.' },
|
|
283
|
+
{ name: "c.array", signature: "(a: Json[]) => Value", description: 'Array constant (JSON string) \u2192 tag "const:array". Plain JSON literals only \u2014 a nested tagged value is rejected, same as c.obj.' },
|
|
284
|
+
{ name: "c.expression", signature: "(source: string) => Value", description: `Xano Expression Engine source, passed through VERBATIM \u2192 tag "const:expr2". The string IS the expression: c.expression('"Hi, " ~ $input.name'), c.expression("$var.price * $var.qty"). \u26A0\uFE0F NOT VALIDATED \u2014 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) \u2014 prefer ref/inp/col, withFilters+fl.*, and obj() (which BUILDS a checked expression). Not the expr() condition builder.` },
|
|
285
|
+
{ name: "c.expressionLegacy", signature: "(source: string) => Value", legacy: true, description: "the older `const:expr` expression form, emitted by codegen for workspaces that still hold one \u2014 author `c.expression` instead." },
|
|
286
|
+
{ name: "c.now", signature: "() => Value", description: "Current time as epoch-ms \u2014 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." },
|
|
287
|
+
{ name: "obj", signature: "(fields: Record<string, Value | nested>) => ObjValue<typeof fields>", description: 'Dynamic object value \u2192 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 \u2014 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 \u2014 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 \u2014 `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 \u2014 build those in a prior step and ref() them. Use for e.g. s.ai.agent.run args.' },
|
|
288
|
+
{ name: "ref", signature: "(name: string, opts?: { safe?: boolean }) => Value", description: 'Reference a stack variable \u2192 tag "var". Pass { safe: true } for null-safe nested access \u2014 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.' },
|
|
289
|
+
{
|
|
290
|
+
name: "inp",
|
|
291
|
+
signature: "(name: string) => Value",
|
|
292
|
+
description: 'Reference a function/endpoint input \u2192 tag "input". Resolves ONLY against the `input` block of the def it sits in \u2014 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 \u2014 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`).'
|
|
293
|
+
},
|
|
294
|
+
{ name: "col", signature: "(name: string) => Value", description: 'Reference a table column \u2192 tag "col".' },
|
|
295
|
+
{ name: "auth", signature: "(path?: string) => Value", description: 'Reference the authenticated identity (auth("id") \u2192 $auth.id) \u2192 tag "auth".' },
|
|
296
|
+
{ name: "caught", signature: '(path?: "code" | "message" | "name" | "result") => Value', description: 'Read the caught error inside an s.try_catch CATCH arm \u2192 tag "trycatch". Valid ONLY there \u2014 it reads empty in the try/finally arms and outside the statement. Those four fields are all the engine binds (result is the attached payload); bare caught() is the whole error record. \u26A0 For an ENGINE-raised exception only `code` and `name` are populated; for an `s.throw`, `message` is the fixed string "Throw Error Statement" and your text is in `result`. So `caught("name")` is useful in both cases and `caught("message")` in NEITHER.' },
|
|
297
|
+
{ name: "toolset", signature: '(path: "token" | "params" | `params.${string}`) => Value', description: `Read a toolset-scoped binding inside a tool \u2192 tag "toolset". The engine binds two: token (the calling URL's token, null when absent) and params (its parameters, as an object); a dotted params.<key> reads one parameter out of that object. Bound only while a tool runs under its toolset \u2014 anywhere else it reads empty.` },
|
|
298
|
+
{ name: "env", signature: "(name: string) => Value", description: 'Read a WORKSPACE environment variable (set via workspaceConfig({ env }) or the dashboard) \u2192 `$env.NAME`. Compiles to tag "setting" with the plain name. env("remote_ip") reads a user var named remote_ip, not the caller IP \u2014 use sys.remoteIp() for that.' },
|
|
299
|
+
{ name: "setting", signature: "(name: string) => Value", description: 'Reference a workspace setting \u2192 tag "setting". Built-in system vars are $-prefixed settings, e.g. setting("$remote_ip"); prefer the typed sys.* accessors.' },
|
|
300
|
+
{ name: "sys.*", signature: "() => Value", description: 'Built-in system / request-context variables \u2192 tag "setting" ($-prefixed). Accessors: remoteIp, requestMethod, requestUri, requestQueryString, httpHeaders, requestAuthToken, apiBaseUrl, datasource, branch, tenant, release, platform, isDebugger. In XanoScript these are $env.$remote_ip etc.; sys.remoteIp() is the public-endpoint rate-limit key (auth("id") is null there).' },
|
|
301
|
+
{ name: "filter", signature: "(name: string, ...args: Value[]) => FilterXdo", description: "Build a filter-chain entry by raw name (escape hatch)." },
|
|
302
|
+
{ name: "fl.*", signature: "(...args: Value[]) => FilterXdo", description: "Typed value-pipeline filters; see the `filters` catalog." },
|
|
303
|
+
{ name: "withFilters", signature: "(value: Value, ...filters: FilterXdo[]) => Value", description: "Attach a filter chain to a value (filters passed spread; an array is also accepted)." }
|
|
304
|
+
];
|
|
305
|
+
var FIELD_DESCRIPTORS = [
|
|
306
|
+
{ name: "text", stored: "text" },
|
|
307
|
+
{ name: "int", stored: "int" },
|
|
308
|
+
{ name: "decimal", stored: "decimal" },
|
|
309
|
+
{ name: "bool", stored: "bool" },
|
|
310
|
+
{ name: "uuid", stored: "uuid" },
|
|
311
|
+
{ name: "date", stored: "date" },
|
|
312
|
+
{ name: "email", stored: "email" },
|
|
313
|
+
{ name: "password", stored: "password" },
|
|
314
|
+
{ name: "json", stored: "json" },
|
|
315
|
+
{ name: "timestamp", stored: "epochms" },
|
|
316
|
+
{ name: "image", stored: "blob_img" },
|
|
317
|
+
{ name: "video", stored: "blob_video" },
|
|
318
|
+
{ name: "audio", stored: "blob_audio" },
|
|
319
|
+
{ name: "attachment", stored: "blob" },
|
|
320
|
+
// Input-only: a raw upload is the request's bytes, not something a table holds.
|
|
321
|
+
{ name: "file", stored: "file", inputOnly: true },
|
|
322
|
+
// Input-only: a column linking a whole table is a foreign key (tableRef).
|
|
323
|
+
{ name: "dbLink", stored: "<tableGuid>_mvpschema", inputOnly: true },
|
|
324
|
+
{ name: "geo.point", stored: "geo_point" },
|
|
325
|
+
{ name: "geo.multipoint", stored: "geo_multipoint" },
|
|
326
|
+
{ name: "geo.linestring", stored: "geo_linestring" },
|
|
327
|
+
{ name: "geo.multilinestring", stored: "geo_multilinestring" },
|
|
328
|
+
{ name: "geo.polygon", stored: "geo_polygon" },
|
|
329
|
+
{ name: "geo.multipolygon", stored: "geo_multipolygon" },
|
|
330
|
+
{ name: "enum", stored: "enum" },
|
|
331
|
+
{ name: "vector", stored: "vector" },
|
|
332
|
+
{ name: "object", stored: "obj" },
|
|
333
|
+
{ name: "tableRef", stored: "int", methodKey: "tableRef" }
|
|
334
|
+
];
|
|
335
|
+
function buildFieldTypes() {
|
|
336
|
+
return FIELD_DESCRIPTORS.map(({ name, stored, methodKey, inputOnly }) => ({
|
|
337
|
+
name,
|
|
338
|
+
stored,
|
|
339
|
+
methods: Object.keys(FIELD_METHODS[methodKey ?? name] ?? {}),
|
|
340
|
+
...inputOnly ? { inputOnly: true } : {}
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
function buildFilters() {
|
|
344
|
+
return FILTER_NAMES.map((name) => {
|
|
345
|
+
const spec = FILTER_SPECS[name];
|
|
346
|
+
const entry = { name, fl: `fl.${name}`, typed: !!spec };
|
|
347
|
+
if (spec?.args?.length) entry.args = spec.args;
|
|
348
|
+
if (spec?.variadic) entry.variadic = true;
|
|
349
|
+
if (spec?.result) entry.result = spec.result;
|
|
350
|
+
if (spec?.group) entry.group = spec.group;
|
|
351
|
+
if (spec?.description) entry.description = spec.description;
|
|
352
|
+
return entry;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
var SPECS_BY_NAME = new Map(GENERATED_SPECS.map((s) => [s.name, s]));
|
|
356
|
+
var LEGACY_SURFACES = {
|
|
357
|
+
"api.realtime_event": "publishes to the SUPERSEDED workspace-global realtime layer, NOT to a `realtimeChannel()` \u2014 its `channel` is a string against that layer, so pointing it at a current-layer channel path publishes into the void. Use `s.realtime.publish` instead: it names the owning `realtimeServer()`, so it addresses a real `realtimeChannel()`."
|
|
358
|
+
};
|
|
359
|
+
var OVERRIDDEN_SURFACES = /* @__PURE__ */ new Set([
|
|
360
|
+
"mvp:api_request",
|
|
361
|
+
"mvp:streaming_api_request",
|
|
362
|
+
"mvp:connect_webflow_api_request",
|
|
363
|
+
"mvp:microservice_request"
|
|
364
|
+
]);
|
|
365
|
+
var STATEMENT_RESULTS = {
|
|
366
|
+
// db.* — mirrors the curated "Runtime behavior" block and InferResponse (#105/#145).
|
|
367
|
+
"db.get": { name: "as", type: "InferRow<T> | null", note: "binds null on a miss, never throws" },
|
|
368
|
+
"db.add": { name: "as", type: "InferRow<T>", note: "the full inserted row incl. id/created_at" },
|
|
369
|
+
"db.edit": { name: "as", type: "InferRow<T>", note: "the full post-mutation row; throws NotFound on a miss" },
|
|
370
|
+
"db.patch": { name: "as", type: "InferRow<T>", note: "the full post-mutation row; throws NotFound on a miss" },
|
|
371
|
+
"db.add_or_edit": { name: "as", type: "InferRow<T>", note: "upserts and never misses" },
|
|
372
|
+
"db.del": { name: "as", type: "null", note: "the engine deletes and returns no value; throws NotFound on a miss" },
|
|
373
|
+
"db.has": { name: "as", type: "boolean" },
|
|
374
|
+
"db.query": { name: "as", type: "InferRow<T>[]", note: "a paging envelope when metadata paging is on" },
|
|
375
|
+
"db.bulk.patch": { name: "as", type: "InferRow<T>[]" },
|
|
376
|
+
"db.bulk.delete": { name: "as", type: "number", note: "count of deleted rows" },
|
|
377
|
+
// security.check_password binds a boolean (does the plaintext match the stored hash), #109/#145.
|
|
378
|
+
"security.check_password": {
|
|
379
|
+
name: "as",
|
|
380
|
+
type: "boolean",
|
|
381
|
+
note: "true when the plaintext matches the stored hash. \u26A0 input.password double-hashes \u2014 pass input.text() plaintext"
|
|
382
|
+
},
|
|
383
|
+
// util.* — two whose UNITS and SHAPE cost teams time, and neither is guessable
|
|
384
|
+
// from the signature.
|
|
385
|
+
"util.geo_distance": {
|
|
386
|
+
name: "as",
|
|
387
|
+
type: "number",
|
|
388
|
+
note: "great-circle distance in METRES (a decimal) \u2014 divide by 1000 for km. Identical points return 0"
|
|
389
|
+
},
|
|
390
|
+
"util.ip_lookup": {
|
|
391
|
+
name: "as",
|
|
392
|
+
type: "IpLookupResult | null",
|
|
393
|
+
note: 'NESTED, not flat: { continent: {code,name}, country: {code,name}, region: {code,name}, city: {name}, postal: {code}, location: {latitude, longitude, tz, radius} }. Coordinates are ref("geo.location.latitude"/".longitude"), place names ref("geo.city.name"/"geo.region.name"/"geo.country.name"); radius is KILOMETRES. \u26A0 Every leaf is nullable and region/city/postal commonly ARE null for a routable public address \u2014 that is a normal hit, not a failed lookup. `city` is an OBJECT, so a bare ref("geo.city") into a text column fails on the object and { safe: true } does NOT help; drill to city.name with a fallback. The whole var is null for an unresolvable address'
|
|
394
|
+
},
|
|
395
|
+
// Clearly-typed declarative ops.
|
|
396
|
+
"array.every": { name: "as", type: "boolean" },
|
|
397
|
+
"math.add": { name: "as", type: "number" },
|
|
398
|
+
"math.bitwise.and": { name: "as", type: "number" },
|
|
399
|
+
"math.bitwise.or": { name: "as", type: "number" },
|
|
400
|
+
"math.bitwise.xor": { name: "as", type: "number" },
|
|
401
|
+
"object.keys": { name: "as", type: "string[]" },
|
|
402
|
+
"object.values": { name: "as", type: "unknown[]" },
|
|
403
|
+
"object.entries": { name: "as", type: "[string, unknown][]" }
|
|
404
|
+
};
|
|
405
|
+
var CONTEXT_FIELD_ENUMS = {
|
|
406
|
+
"mvp:precondition:error_type": PRECONDITION_ERROR_TYPES,
|
|
407
|
+
// `access` defaults to `public`, so a misspelling fails OPEN — the file is
|
|
408
|
+
// served to the world. The members were unlisted anywhere, leaving `private`
|
|
409
|
+
// inferable only from `s.storage.sign_private_url`'s existence (#65).
|
|
410
|
+
"mvp:create_attachment:access": STORAGE_ACCESS,
|
|
411
|
+
"mvp:create_audio:access": STORAGE_ACCESS,
|
|
412
|
+
"mvp:create_image:access": STORAGE_ACCESS,
|
|
413
|
+
"mvp:create_video:access": STORAGE_ACCESS,
|
|
414
|
+
"mvp:setheader:duplicates": HEADER_DUPLICATES
|
|
415
|
+
};
|
|
416
|
+
function fieldsOf(spec) {
|
|
417
|
+
return spec.rules.map((r) => {
|
|
418
|
+
const f = {
|
|
419
|
+
name: r.field,
|
|
420
|
+
type: r.type,
|
|
421
|
+
optional: r.optional || r.default !== void 0
|
|
422
|
+
};
|
|
423
|
+
if (r.default !== void 0) f.default = r.default;
|
|
424
|
+
const curated = CONTEXT_FIELD_ENUMS[`${spec.name}:${r.field}`];
|
|
425
|
+
if (r.enum !== void 0) f.enum = r.enum;
|
|
426
|
+
else if (curated) f.enum = [...curated];
|
|
427
|
+
return f;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function buildManifest(opts = {}) {
|
|
431
|
+
const objectKinds = KIND_DESCRIPTORS.filter((d) => !d.unpublished).map((d) => ({
|
|
432
|
+
...d,
|
|
433
|
+
registered: isRegisteredKind(d.kind),
|
|
434
|
+
// A published kind can still have unpublished sub-kinds (the two realtime
|
|
435
|
+
// lifecycle trigger types under `trigger`), so filter that level too.
|
|
436
|
+
...d.subKinds ? { subKinds: d.subKinds.filter((sub) => !sub.unpublished) } : {}
|
|
437
|
+
}));
|
|
438
|
+
const statements = STATEMENT_SURFACES.map(([surface, storedName]) => {
|
|
439
|
+
const spec = SPECS_BY_NAME.get(storedName);
|
|
440
|
+
const overridden = OVERRIDDEN_SURFACES.has(storedName);
|
|
441
|
+
const entry = {
|
|
442
|
+
surface,
|
|
443
|
+
storedName,
|
|
444
|
+
sPath: sPathOf(surface),
|
|
445
|
+
registered: isRegisteredStatement(storedName),
|
|
446
|
+
declarative: spec !== void 0 && !overridden
|
|
447
|
+
};
|
|
448
|
+
if (spec) {
|
|
449
|
+
entry.output = spec.output ?? false;
|
|
450
|
+
if (!overridden) entry.fields = fieldsOf(spec);
|
|
451
|
+
}
|
|
452
|
+
if (Object.hasOwn(STATEMENT_RESULTS, surface)) entry.result = STATEMENT_RESULTS[surface];
|
|
453
|
+
if (Object.hasOwn(LEGACY_SURFACES, surface)) entry.legacy = true;
|
|
454
|
+
return entry;
|
|
455
|
+
});
|
|
456
|
+
const filters = buildFilters();
|
|
457
|
+
return {
|
|
458
|
+
name: "xanots",
|
|
459
|
+
version: opts.version ?? "0.0.0",
|
|
460
|
+
description: "TypeScript SDK that compiles a typed Xano workspace into the importable packageExport JSON bundle.",
|
|
461
|
+
coverage: {
|
|
462
|
+
// Counted over the ENGINE's catalog, not over this SDK's kinds: the engine
|
|
463
|
+
// has one object kind per trigger type where the SDK has one `trigger`
|
|
464
|
+
// kind with sub-kinds, so counting SDK kinds reported 16/30 for a surface
|
|
465
|
+
// that actually authors 23 of them.
|
|
466
|
+
objectKinds: {
|
|
467
|
+
implemented: IMPLEMENTED_OBJECT_KINDS.length,
|
|
468
|
+
total: TOTAL_OBJECT_KINDS,
|
|
469
|
+
unmodeled: unmodeledObjectKinds()
|
|
470
|
+
},
|
|
471
|
+
statements: {
|
|
472
|
+
implemented: statements.filter((s) => s.registered).length,
|
|
473
|
+
total: TOTAL_STATEMENTS
|
|
474
|
+
},
|
|
475
|
+
filters: { typed: filters.filter((f) => f.typed).length, total: filters.length }
|
|
476
|
+
},
|
|
477
|
+
values: { constructors: [...VALUE_CONSTRUCTORS], tags: TAGS },
|
|
478
|
+
objectKinds,
|
|
479
|
+
fieldTypes: buildFieldTypes(),
|
|
480
|
+
statements,
|
|
481
|
+
filters,
|
|
482
|
+
cli: buildCli(),
|
|
483
|
+
cliGlobalFlags: buildCliGlobalFlags()
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
var DEFAULT_KEEP = /* @__PURE__ */ new Set([
|
|
487
|
+
"storage.create_image:access",
|
|
488
|
+
"storage.create_attachment:access",
|
|
489
|
+
"storage.create_audio:access",
|
|
490
|
+
"storage.create_video:access"
|
|
491
|
+
]);
|
|
492
|
+
function preconditionStatusLine() {
|
|
493
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
494
|
+
for (const type of PRECONDITION_ERROR_TYPES) {
|
|
495
|
+
const status = PRECONDITION_ERROR_STATUS[type];
|
|
496
|
+
byStatus.set(status, [...byStatus.get(status) ?? [], type]);
|
|
497
|
+
}
|
|
498
|
+
return [...byStatus].sort(([a], [b]) => a - b).map(([status, types]) => `${types.map((t) => `\`${t}\``).join("/")} \u2192 ${status}`).join(", ");
|
|
499
|
+
}
|
|
500
|
+
var fieldLine = (f, sPath) => {
|
|
501
|
+
const keepDefault = f.default !== void 0 && DEFAULT_KEEP.has(`${sPath}:${f.name}`);
|
|
502
|
+
const type = f.enum ? f.enum.map((v) => JSON.stringify(v)).join(" | ") : f.type;
|
|
503
|
+
return `${f.name}${f.optional ? "?" : ""}: ${type}${keepDefault ? ` = ${JSON.stringify(f.default)}` : ""}`;
|
|
504
|
+
};
|
|
505
|
+
var FILTER_NOTES = {
|
|
506
|
+
// "Direction" family — which operand is the subject is genuinely confusing.
|
|
507
|
+
contains: "piped value is the subject text; the arg is the substring searched for",
|
|
508
|
+
ends_with: "piped value is the subject text; the arg is the substring searched for",
|
|
509
|
+
starts_with: "piped value is the subject text; the arg is the substring searched for",
|
|
510
|
+
icontains: "case-insensitive; piped value is the subject, the arg is the substring",
|
|
511
|
+
iends_with: "case-insensitive; piped value is the subject, the arg is the substring",
|
|
512
|
+
istarts_with: "case-insensitive; piped value is the subject, the arg is the substring",
|
|
513
|
+
// "empty" is a specific set of values, not just null.
|
|
514
|
+
filter_empty: 'keeps entries that are not empty ("", null, 0, "0", false, [], {})',
|
|
515
|
+
first_notempty: 'first value that is not empty ("", null, 0, "0", false, [], {})',
|
|
516
|
+
// The `code` arg is a JS FUNCTION BODY (it must `return`), not a column path,
|
|
517
|
+
// and which identifiers it can see depends on the filter — see **Lambda bodies**.
|
|
518
|
+
// Build it with `lam.fn`, which makes the bindings the function's parameters.
|
|
519
|
+
map: "`code` is a JS body run per element, over `$this`/`$index`/`$parent` \u2014 build it with `lam.fn`",
|
|
520
|
+
filter: "`code` is a JS body run per element (keep it? true/false), over `$this`/`$index`/`$parent`",
|
|
521
|
+
every: "`code` is a JS body run per element (true for all?), over `$this`/`$index`/`$parent`",
|
|
522
|
+
some: "`code` is a JS body run per element (true for any?), over `$this`/`$index`/`$parent`",
|
|
523
|
+
find: "`code` is a JS body run per element; returns the first element it accepts",
|
|
524
|
+
findIndex: "`code` is a JS body run per element; returns the first matching index",
|
|
525
|
+
reduce: "`code` is a JS body run per element; the ACCUMULATOR is `$result` (there is no `$acc`) and `initial_value` is REQUIRED \u2014 omitting it would slot the code as the initial value",
|
|
526
|
+
lambda: "runs a JS body once over the piped value, which it binds as `$this` (NOT `$parent`)",
|
|
527
|
+
// The one filter next to `lambda` that is NOT a lambda. It reads as one, its
|
|
528
|
+
// upstream description names a `$this` that does not exist on its path, and
|
|
529
|
+
// both wrong spellings can return a plausible value with HTTP 200 — so the
|
|
530
|
+
// note has to say what the binding IS, not only what it isn't.
|
|
531
|
+
transform: '`expression` is Xano Expression Engine source, NOT a JS body \u2014 no `return`, and the piped value is `$0` (or `$$`), NOT `$this` (which is null here). `$var`/`$input`/`$env`/`$auth` resolve and filters pipe inside it: `$0 * 2`, `$0|sort|join:","`. Parenthesize a pipe inside an object literal \u2014 `{ s: ($0|sort|join:",") }` \u2014 or its comma is read as the key separator and later keys vanish silently. For JavaScript use `lambda`',
|
|
532
|
+
// The sort mode is the whole behavior of this filter, and picking it wrong is
|
|
533
|
+
// SILENT — every unrecognized spelling falls through to `itext`, so the array
|
|
534
|
+
// comes back sorted as case-insensitive text with no error anywhere. That is
|
|
535
|
+
// how "top N by score/distance/recency" comes out wrong (#198).
|
|
536
|
+
fsort: '`type` is the comparator, and ONLY "number" compares numerically \u2014 "text"/"itext" are strcmp/strcasecmp, "natural"/"inatural" are the human-readable "a2 < a10" orderings. Default "itext". Anything else silently sorts as text, so a numeric sort MUST spell "number"; the path arg drills into each element',
|
|
537
|
+
// The CSV pair reads as interchangeable and is not: only `csv_create` writes a
|
|
538
|
+
// header, and `csv_encode`'s per-row column order misaligns heterogeneous rows
|
|
539
|
+
// with no error at all (#246).
|
|
540
|
+
csv_encode: "writes NO header \u2014 values only, each row in THAT row's key order with no normalization across rows, so rows whose keys differ in order or count silently misalign columns. Nested cells are JSON-encoded and `false` writes empty. A piped array of SCALARS is treated as one row. Use `csv_create` for a header",
|
|
541
|
+
csv_create: "the header-writing counterpart to `csv_encode`: the PIPED value is the list of column names (written as the header line) and `rows` carries the data rows",
|
|
542
|
+
// A group-by whose name reads as a lookup table. The singular spelling
|
|
543
|
+
// `idx[key].name` is null at runtime rather than an error (#267).
|
|
544
|
+
index_by: "a GROUP-BY: every value is an ARRAY of the items sharing that key, even when only one does, so a lookup reads `idx[key][0]`. Items whose path is missing or non-scalar are dropped",
|
|
545
|
+
// Non-obvious names.
|
|
546
|
+
epochms_transform: 'applies a relative shift (e.g. "+1 day") to the timestamp',
|
|
547
|
+
unpick: "returns the object without the named keys (inverse of a pick)"
|
|
548
|
+
};
|
|
549
|
+
function wrapList(items, indent, width = 84) {
|
|
550
|
+
const lines = [];
|
|
551
|
+
let line = "";
|
|
552
|
+
for (const item of items) {
|
|
553
|
+
const next = line === "" ? indent + item : `${line} \xB7 ${item}`;
|
|
554
|
+
if (next.length > width && line !== "") {
|
|
555
|
+
lines.push(line + " \xB7");
|
|
556
|
+
line = indent + item;
|
|
557
|
+
} else {
|
|
558
|
+
line = next;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (line !== "") lines.push(line);
|
|
562
|
+
return lines;
|
|
563
|
+
}
|
|
564
|
+
function renderLambdaSection() {
|
|
565
|
+
const lines = ["## Lambda bodies (JavaScript)", ""];
|
|
566
|
+
lines.push(
|
|
567
|
+
"**A lambda is an escape hatch, not a default.** The body runs outside the request's own",
|
|
568
|
+
"runtime, and a workspace has a BOUNDED pool of lambda workers every lambda in it shares",
|
|
569
|
+
"\u2014 so a call both crosses a process boundary and draws on a workspace-wide resource.",
|
|
570
|
+
"Reach for one only when the typed surface cannot express the work: if a native filter,",
|
|
571
|
+
"an `expr(...)`/`obj(...)` expression, or a plain statement can, use that. The crossing",
|
|
572
|
+
"is per CALL, not per element \u2014 an iterating filter sends the body ONCE and loops on the",
|
|
573
|
+
"other side, so one body over a whole list beats one called from inside a stack loop.",
|
|
574
|
+
"",
|
|
575
|
+
"The lambda statement (`s.lambda({ as, code, timeout? })`) and eight filters run a",
|
|
576
|
+
"JavaScript body. **Write the body as a FUNCTION, not a `c.text` string** \u2014 the",
|
|
577
|
+
"bindings are its parameters, so the editor supplies them and a wrong name is a",
|
|
578
|
+
"compile error instead of a wrong value at runtime. Write it inline and the surface",
|
|
579
|
+
"is implied by where it sits; nothing names one:",
|
|
580
|
+
"",
|
|
581
|
+
"```ts",
|
|
582
|
+
"fl.map(({ $this }) => $this * 2) // map's bindings, typed from the position",
|
|
583
|
+
"fl.reduce({ initial_value: 0, code: ({ $result, $this }) => $result + $this })",
|
|
584
|
+
's.lambda({ as: "total", code: ({ $var }) => $var.subtotal * 1.2 }) // ambient only \u2014 $this is a compile error',
|
|
585
|
+
"```",
|
|
586
|
+
"",
|
|
587
|
+
"The parameters are a fiction \u2014 only the BODY is sent, and the engine injects the",
|
|
588
|
+
"bindings as free identifiers \u2014 so DESTRUCTURE them. `(b) => b.$this` emits",
|
|
589
|
+
"`return b.$this`, and `b` is undefined at runtime (the SDK refuses it).",
|
|
590
|
+
"",
|
|
591
|
+
"\u26A0 An inline `code:` arrow receives BINDINGS ONLY. `capture` is an option of",
|
|
592
|
+
"`lam.fn`, not a field of `s.lambda` or of a filter \u2014 to pass data in, move the",
|
|
593
|
+
"body into `lam.fn(fn, { capture })` (below). Writing `capture:` beside `code:`",
|
|
594
|
+
"is a type error, and the fix is to relocate the body, not to drop the field.",
|
|
595
|
+
"",
|
|
596
|
+
"For a body built away from its call site:",
|
|
597
|
+
"",
|
|
598
|
+
"- `lam.fn(({ $result, $this }) => $result + $this, { surface?, capture? })` \u2014 name a `surface` to check it here, or omit it and the call site checks it.",
|
|
599
|
+
'- `lam.raw("return 1", { surface })` \u2014 text, same validation.',
|
|
600
|
+
'- `lam.file("./lambdas/total.ts")` \u2014 a default-exported function in its own type-checked module, read as text at build time. The deterministic option under a bundler, where a function\'s source is whatever the bundler emitted. NODE ONLY, and it is the `lam` import that changes: `import { lam } from "@xanots/sdk/node"`. The isomorphic `lam` has no `file` (no filesystem in a browser bundle); its `fn` and `raw` are the same functions.',
|
|
601
|
+
"",
|
|
602
|
+
"Nothing from the enclosing scope crosses implicitly. The body is sent as TEXT and runs",
|
|
603
|
+
"in a different process, so a closed-over `const rate` is undefined there, and the body",
|
|
604
|
+
"throwing on it returns the diagnostic text with HTTP 200 \u2014 a wrong VALUE, not an error.",
|
|
605
|
+
"Put what the body needs in `capture`; it arrives as the SECOND parameter and is emitted",
|
|
606
|
+
"ahead of the body as a `const` prelude:",
|
|
607
|
+
"",
|
|
608
|
+
"```ts",
|
|
609
|
+
'lam.fn(({ $this }, { capturedRate }) => $this * capturedRate, { surface: "map", capture: { capturedRate: rate } })',
|
|
610
|
+
"```",
|
|
611
|
+
"",
|
|
612
|
+
"\u26A0 A capture key must NOT share its name with a module-scope binding. An inline body is",
|
|
613
|
+
"recovered with `toString()`, and a `.ts` loader renames one of two same-named bindings \u2014",
|
|
614
|
+
"so the body reads `rate2` while the prelude declares `rate`, and `rate2` is undefined at",
|
|
615
|
+
"runtime (the body throws and the engine returns that text in the value slot with HTTP",
|
|
616
|
+
"200 \u2014 a wrong value, not an error). Build time refuses it. The key does not have to keep",
|
|
617
|
+
"the name of what it carries: `capture: { capturedRate: rate }` above is the safe form.",
|
|
618
|
+
"",
|
|
619
|
+
"Capture JSON data only \u2014 string, number, boolean, null, object, array. A function,",
|
|
620
|
+
"`undefined`, `symbol` or `bigint` has no JSON form that survives, and a `Date`/`Map`/",
|
|
621
|
+
"`Set`/`RegExp` has one that LIES (a `Date` arrives as a string, the rest as `{}`), so",
|
|
622
|
+
"all of them are refused at build time \u2014 the class ones at any depth. Capture the plain",
|
|
623
|
+
"form and rebuild in the body (`d.getTime()` \u2192 `new Date(d)`). The captured type flows into",
|
|
624
|
+
"that second parameter (no explicit type arguments), and an object may be declared as an",
|
|
625
|
+
"`interface` or a `type` alias alike.",
|
|
626
|
+
"",
|
|
627
|
+
"A body is a FUNCTION BODY: it must `return` its value. Bindings by surface \u2014 an",
|
|
628
|
+
"identifier outside its surface's set is undefined at runtime, and the SDK refuses",
|
|
629
|
+
"it at build time whichever spelling you use:",
|
|
630
|
+
""
|
|
631
|
+
);
|
|
632
|
+
const ambient = LAMBDA_BINDINGS["s.lambda"];
|
|
633
|
+
const tick = (x) => `\`${x}\``;
|
|
634
|
+
lines.push(
|
|
635
|
+
`- every surface: ${ambient.map(tick).join(" \xB7 ")} (+ the ${LAMBDA_GLOBALS.map(tick).join(" / ")} globals)`
|
|
636
|
+
);
|
|
637
|
+
const extras = (surface) => LAMBDA_BINDINGS[surface].filter((b) => !ambient.includes(b));
|
|
638
|
+
const byExtras = /* @__PURE__ */ new Map();
|
|
639
|
+
for (const surface of Object.keys(LAMBDA_BINDINGS)) {
|
|
640
|
+
if (surface === "s.lambda") continue;
|
|
641
|
+
const key = extras(surface).join(" ");
|
|
642
|
+
byExtras.set(key, [...byExtras.get(key) ?? [], surface]);
|
|
643
|
+
}
|
|
644
|
+
for (const [key, surfaces] of byExtras) {
|
|
645
|
+
const label = surfaces.map((x) => tick(x.includes(".") ? x : `fl.${x}`)).join(" \xB7 ");
|
|
646
|
+
lines.push(`- ${label}: + ${key.split(" ").map(tick).join(" \xB7 ")}`);
|
|
647
|
+
}
|
|
648
|
+
lines.push(
|
|
649
|
+
`- \`s.lambda\`: ambient only \u2014 no \`$this\`, no \`$parent\`, no \`$result\`.`,
|
|
650
|
+
"",
|
|
651
|
+
"`$result` is `reduce`'s ACCUMULATOR (there is no `$acc`). `$this` is the element in",
|
|
652
|
+
"an iterating filter and the piped value in `fl.lambda`; `$parent` is the whole array",
|
|
653
|
+
"and exists only on the iterating filters. A stack variable is reached as",
|
|
654
|
+
"`$var.name` \u2014 it is NOT also injected as a bare `$name`.",
|
|
655
|
+
"",
|
|
656
|
+
"Four hazards and the dependency route, all live-verified:",
|
|
657
|
+
"",
|
|
658
|
+
"- \u26A0 A body that THROWS does not fail the request: the engine returns its diagnostic",
|
|
659
|
+
" TEXT as the value with HTTP 200, so the failure reads as bad data. Validate before",
|
|
660
|
+
" consuming a lambda result numerically, and prefer a `lam.*` body, which cannot fail",
|
|
661
|
+
" this way for a binding reason.",
|
|
662
|
+
"- \u26A0 `timeout` bounds a body that AWAITS, not one that spins. It is COOPERATIVE \u2014 only",
|
|
663
|
+
" observed at an `await` \u2014 so synchronous work runs to completion however long it takes:",
|
|
664
|
+
" a 1s `timeout` over a body that busy-loops for 3s lets it run all 3 and return",
|
|
665
|
+
" normally. Treat it as a bound on WAITING (a slow `fetch`), not a kill switch on",
|
|
666
|
+
" compute \u2014 if a loop could run away, bound it yourself inside the body.",
|
|
667
|
+
"- \u26A0 A top-level `import`/`export` is a syntax error \u2014 the body is a function body, not",
|
|
668
|
+
" a module. Reach a dependency through the PRELOADED globals below, which need no",
|
|
669
|
+
' specifier. A dynamic `import("\u2026")` or `require("\u2026")` with a LITERAL specifier is not',
|
|
670
|
+
" portable: on an instance that bundles the body before running it, every literal",
|
|
671
|
+
" specifier is resolved ahead of time against a filesystem where none of them exist, so",
|
|
672
|
+
' `await import("node:crypto")` comes back as the TEXT `Could not resolve "node:crypto"`',
|
|
673
|
+
" with HTTP 200. Other instances resolve it at run time and it works \u2014 so it is",
|
|
674
|
+
" instance-dependent, and only the globals are not.",
|
|
675
|
+
"- Preloaded globals, live-probed \u2014 no specifier, so these work everywhere:",
|
|
676
|
+
...wrapList(LAMBDA_MODULE_GLOBALS.map(tick), " "),
|
|
677
|
+
" \u2026plus `fetch`, `Buffer`, `TextEncoder`/`TextDecoder`, and the `crypto` above",
|
|
678
|
+
" (`randomUUID`, `createHmac`, `subtle` all present). `Object.keys(globalThis)`",
|
|
679
|
+
" inside a body lists whatever else a given instance carries.",
|
|
680
|
+
"- \u26A0 `console` output goes to the request LOG, not stdout. `log` \xB7 `error` \xB7 `warn` \xB7",
|
|
681
|
+
" `info` \xB7 `debug` \xB7 `trace` all route there; the body's `console` is a purpose-built",
|
|
682
|
+
" object, so anything outside that set is undefined and CALLING it throws \u2014 which,",
|
|
683
|
+
" per the first hazard, replaces the return value with the error text at HTTP 200.",
|
|
684
|
+
"",
|
|
685
|
+
"TypeScript annotations survive in the body, and top-level `await` works.",
|
|
686
|
+
""
|
|
687
|
+
);
|
|
688
|
+
return lines;
|
|
689
|
+
}
|
|
690
|
+
var NAVIGATION_SLOT = "<!--navigation-->";
|
|
691
|
+
function renderTestsSection() {
|
|
692
|
+
return [
|
|
693
|
+
"## Saved unit tests",
|
|
694
|
+
"",
|
|
695
|
+
"Named input sets run against one object, with assertions on its response \u2014 the tests the Xano editor shows. NOT `workflowTest()`, which is a standalone object with its own stack that calls other objects.",
|
|
696
|
+
"",
|
|
697
|
+
"- `tests?: TestDef[]` on `query()`, `defineFunction()`, and `middleware()`. No other kind stores them.",
|
|
698
|
+
"- `{ name, id?, description?, datasource?, input?, expect?, token? }`.",
|
|
699
|
+
"- `name` is unique within the object and is the key a statement's `mock` resolves against; a duplicate throws.",
|
|
700
|
+
"- `id` defaults to a derivation from the owning object plus the name. A pulled test carries the id Xano minted, emitted explicitly.",
|
|
701
|
+
'- `datasource` defaults to `""` \u2014 an EMPTY datasource. Any other value names one the engine CLONES before the run; `"live"` warns at encode time.',
|
|
702
|
+
'- \u26A0 That empty default means **no `table({ seed })` rows exist while a unit test runs**, exactly as for a `workflowTest()` \u2014 every `db` read misses and a count or index assertion (`resp("0.id")`) fails against a deployment whose endpoint returns those rows over HTTP a second later. Create what the test needs INSIDE the run \u2014 a `defineFunction` fixture the stack calls first \u2014 or `mock` the read.',
|
|
703
|
+
"- `input` is `{ <input name>: Value }` \u2014 tagged values (`c.*`, `ref`, \u2026), never plain JS scalars.",
|
|
704
|
+
"- `token` runs the test as an authenticated caller. A pull does NOT bring it back: it is an expiring credential, reported as a deliberate omission.",
|
|
705
|
+
"",
|
|
706
|
+
"### Assertions: `expect.*`, not `s.expect.*`",
|
|
707
|
+
"",
|
|
708
|
+
"- `expect.*` builds a `{type, vars}` record stored on a `tests` entry. `s.expect.*` builds a STATEMENT for a `workflowTest()` stack. Different return types; not interchangeable.",
|
|
709
|
+
"- Subject first \u2014 argument order is the assertion: `expect.to_equal(resp(), c.int(2))`.",
|
|
710
|
+
'- `resp()` is the response under test; `resp("id")` drills, `resp("a-b")` takes the bracket escape.',
|
|
711
|
+
"- Subject only: `to_be_defined`, `to_not_be_defined`, `to_be_empty`, `to_be_null`, `to_not_be_null`, `to_be_true`, `to_be_false`, `to_be_in_the_future`, `to_be_in_the_past`.",
|
|
712
|
+
"- Subject + operand: `to_equal`, `to_not_equal`, `to_be_greater_than`, `to_be_less_than`, `to_contain`, `to_start_with`, `to_end_with`, `to_match` (operand is a delimiter-wrapped pattern \u2014 build it with `c.regex`).",
|
|
713
|
+
"- `expect.to_be_within(subject, min, max)` \u2014 both bounds EXCLUSIVE; a subject equal to either fails.",
|
|
714
|
+
"- `expect.to_throw(exception?)` takes NO subject. With an argument the error message must CONTAIN that text (case-insensitive substring); with none, any error passes.",
|
|
715
|
+
"",
|
|
716
|
+
"### Mocks",
|
|
717
|
+
"",
|
|
718
|
+
"A statement returns a value instead of doing its work, for one named test.",
|
|
719
|
+
"",
|
|
720
|
+
'- `s.set_var("x", c.int(1), { mock: { "adds one": c.int(123) } })`. Every statement takes `mock`.',
|
|
721
|
+
"- Keyed by TEST NAME. A name the object does not declare THROWS at encode: the engine ignores a mock whose key is not a real test id, so it would deploy clean and silently never apply.",
|
|
722
|
+
"- `{ value, enabled: false }` keeps a mock stored but switched off.",
|
|
723
|
+
"- A mock applies ONLY while its test runs. It changes nothing about a normal request.",
|
|
724
|
+
"",
|
|
725
|
+
"### `example`",
|
|
726
|
+
"",
|
|
727
|
+
"`example?: { input?, output? }` on `query()` \u2014 the saved request/response sample the editor records. Free-form JSON, not tagged values. A pull DOES bring it back.",
|
|
728
|
+
"",
|
|
729
|
+
"### What a `workflowTest()` run actually sees",
|
|
730
|
+
"",
|
|
731
|
+
"The run is isolated in ways that make a correct test fail for reasons the failure message does not name.",
|
|
732
|
+
"",
|
|
733
|
+
"- The run uses an EMPTY datasource, so **no `table({ seed })` rows exist while it runs** and every `db` read misses. A test that buys seeded row 1 fails with its own precondition message, which reads as a wrong id rather than an empty database. Create what the test needs INSIDE the test \u2014 typically a `defineFunction` fixture the stack calls first.",
|
|
734
|
+
'- `s.api.call` does NOT raise when the endpoint answers with an error. It BINDS the error envelope (`{code, message}`) to its `as` and carries on, so a later `s.expect.to_be_defined({ expr: ref("r.field") })` reports the ASSERTION while the real failure was the call, four statements up. Assert on the envelope \u2014 `s.expect.to_contain({ expr: ref("r.code"), value: c.text("ERROR_CODE_INPUT_ERROR") })` \u2014 when a call may fail. `s.function.run` raises instead; the two disagree.',
|
|
735
|
+
"- `s.expect.to_throw({ body, exception? })` runs `body` in an ISOLATED var stack, so a variable bound EARLIER in the test is not visible inside it \u2014 bind what the body needs inside the body. `exception` is text the raised message must CONTAIN; omit it to accept any error.",
|
|
736
|
+
"- `s.expect.to_throw` catches such a call only when the error carries a MESSAGE. `ERROR_CODE_ACCESS_DENIED` arrives with an empty one, so `to_throw` around an auth-refused call reports `to_throw failed - response is ok` \u2014 which reads as a broken auth gate on a gate that works.",
|
|
737
|
+
"- An endpoint's `auth` gate is NOT enforced on `s.api.call`. A `query({ auth: users })` runs anyway and fails only where its stack dereferences `auth(...)`. A stack that never touches `auth(...)` runs unauthenticated and passes.",
|
|
738
|
+
"- Neither `auth.token` nor an `Authorization` entry in `headers` authenticates the call \u2014 a token that answers 200 over real HTTP is refused here. To cover auth-gated logic, move the body into a `defineFunction` taking the user id and `s.function.call` that; the gate itself is not reachable from a workflow test.",
|
|
739
|
+
"",
|
|
740
|
+
"### Running them",
|
|
741
|
+
"",
|
|
742
|
+
"`xanots test run-all` runs the unit tests AND the `workflowTest()` objects an environment carries. It takes no entry file and compiles nothing: it runs what is DEPLOYED, so deploy before testing.",
|
|
743
|
+
"",
|
|
744
|
+
"- `--dest ephemeral` (DEFAULT, `--name <env>` to pick one), `--dest sandbox`, or `--dest workspace`. Unlike `deploy`, `workspace` is allowed here \u2014 running a test reads.",
|
|
745
|
+
'- `xanots test list` shows what is there without running it; `xanots test run "<name>"` runs one. When a name is ambiguous the error prints the qualified `function:math/happy path` form, which `run` also accepts.',
|
|
746
|
+
"- `--kind unit|workflow` narrows to one family. `--concurrency <n>` defaults to 1: tests share the environment database.",
|
|
747
|
+
"- A failing suite exits 5, distinct from a crash. Tests that could not be REACHED exit 6 \u2014 retry that one, investigate the other. An environment with no tests is success, not failure.",
|
|
748
|
+
"- `xanots deploy ./index.ts --test` deploys and then runs the suite against what it just shipped. A failure exits 5 WITHOUT retracting the deploy \u2014 the environment is live either way.",
|
|
749
|
+
""
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
function renderSections(m) {
|
|
753
|
+
const lines = [];
|
|
754
|
+
lines.push(`# ${m.name} v${m.version}`, "");
|
|
755
|
+
lines.push(`> ${m.description}`, "");
|
|
756
|
+
lines.push(
|
|
757
|
+
"Author a Xano workspace in TypeScript: build typed objects, register them on a",
|
|
758
|
+
"`new Xano()` instance, and call `.export()` to get one importable bundle. Compile",
|
|
759
|
+
"with `xanots export ./xano/index.js`.",
|
|
760
|
+
"",
|
|
761
|
+
`Coverage: object kinds ${m.coverage.objectKinds.implemented}/${m.coverage.objectKinds.total}, statement surfaces ${m.coverage.statements.implemented}/${m.coverage.statements.total}, filters ${m.coverage.filters.total} (${m.coverage.filters.typed} typed).`,
|
|
762
|
+
// The shortfall by name: an agent that cannot see WHICH kinds are missing
|
|
763
|
+
// will invent a factory for one. Reasons live in `manifest.json`.
|
|
764
|
+
`Not authorable here: ${m.coverage.objectKinds.unmodeled.map((k) => k.kind).join(", ")} \u2014 these cannot be authored and do not survive a pull; see \`coverage.objectKinds.unmodeled\` in \`manifest.json\`.`,
|
|
765
|
+
"",
|
|
766
|
+
"This file is the whole always-read surface: the mental model, the deploy contract,",
|
|
767
|
+
"every gotcha, and control flow. Per-surface detail lives in the topic files listed",
|
|
768
|
+
"below \u2014 open the one whose condition matches the task, and skip the rest. For",
|
|
769
|
+
"exhaustive per-entry detail in NEITHER \u2014 a statement's full field schema with engine",
|
|
770
|
+
"defaults, a filter's complete argument list, the engine `storedName` mapping \u2014 do a",
|
|
771
|
+
"TARGETED lookup in the shipped `manifest.json` (grep or `jq` the one entry you need;",
|
|
772
|
+
"it is ~55k tokens, so never read it whole). Its top-level keys are `" + Object.keys(m).join("`, `") + "`. `statements` and `filters` are ARRAYS, not maps \u2014 SELECT, do not index:",
|
|
773
|
+
` jq '.statements[] | select(.sPath=="db.get")' manifest.json`,
|
|
774
|
+
` jq '.filters[] | select(.name=="json_decode")' manifest.json`,
|
|
775
|
+
`Select a statement on \`sPath\` (the \`s.*\` path you write), NOT \`surface\` (the XanoScript term): ${m.statements.filter((s) => s.surface !== s.sPath).length} of ${m.statements.length} differ \u2014 \`var\`\u2192\`set_var\`, \`break\`\u2192\`foreach_break\`, \`foreach.remove\`\u2192\`foreach_remove\`, and every \`expect.*\`.`,
|
|
776
|
+
"",
|
|
777
|
+
NAVIGATION_SLOT
|
|
778
|
+
);
|
|
779
|
+
lines.push(
|
|
780
|
+
"## Quickstart",
|
|
781
|
+
"",
|
|
782
|
+
"Authoring is **declarative def-objects** passed to factories \u2014 there is no",
|
|
783
|
+
"callback/chaining builder.",
|
|
784
|
+
"",
|
|
785
|
+
"```ts",
|
|
786
|
+
'import { workspace, apiGroup, query, table, input, f, ref, inp, auth, s } from "@xanots/sdk";',
|
|
787
|
+
"",
|
|
788
|
+
"const users = table({",
|
|
789
|
+
' name: "users",',
|
|
790
|
+
" auth: true, // backs authentication",
|
|
791
|
+
" // `id` (int PK) + `created_at` (epochms) are auto-injected \u2014 don't declare them.",
|
|
792
|
+
" schema: {",
|
|
793
|
+
" email: f.email({ required: true }),",
|
|
794
|
+
" name: f.text(),",
|
|
795
|
+
" },",
|
|
796
|
+
' // Indexes: { type, fields: [{ name, op? }] }. `"unique"` is shorthand for `"btree|unique"`.',
|
|
797
|
+
" // A DOTTED name indexes a key inside a column (`xdo.email`); only the part before the",
|
|
798
|
+
" // first dot is checked against the schema.",
|
|
799
|
+
' index: [{ type: "unique", fields: [{ name: "email" }] }],',
|
|
800
|
+
"});",
|
|
801
|
+
"",
|
|
802
|
+
"const posts = table({",
|
|
803
|
+
' name: "posts",',
|
|
804
|
+
" schema: {",
|
|
805
|
+
" author: f.tableRef(users), // foreign key \u2192 users (NOT `ref`)",
|
|
806
|
+
" body: f.text({ required: true }),",
|
|
807
|
+
" },",
|
|
808
|
+
"});",
|
|
809
|
+
"",
|
|
810
|
+
'const api = apiGroup({ name: "blog", canonical: "blog" }); // canonical \u2192 the URL token',
|
|
811
|
+
"",
|
|
812
|
+
"const createPost = query({",
|
|
813
|
+
' name: "create_post", verb: "POST", apiGroup: api, auth: users, // the auth table',
|
|
814
|
+
" input: { body: input.text({ required: true }) },",
|
|
815
|
+
" stack: [",
|
|
816
|
+
' s.db.add({ table: posts, row: { author: auth("id"), body: inp("body") }, as: "post" }),',
|
|
817
|
+
" ],",
|
|
818
|
+
' response: ref("post"),',
|
|
819
|
+
"});",
|
|
820
|
+
"",
|
|
821
|
+
"const listPosts = query({",
|
|
822
|
+
' name: "list_posts", verb: "GET", apiGroup: api,',
|
|
823
|
+
' stack: [s.db.query({ table: posts, sort: [{ sortBy: "created_at", dir: "desc" }], as: "rows" })],',
|
|
824
|
+
' response: ref("rows"),',
|
|
825
|
+
"});",
|
|
826
|
+
"",
|
|
827
|
+
'export default workspace("my-blog")',
|
|
828
|
+
" .registerTables([users, posts])",
|
|
829
|
+
" .registerApiGroups([api])",
|
|
830
|
+
" .registerQueries([createPost, listPosts]);",
|
|
831
|
+
"```",
|
|
832
|
+
"",
|
|
833
|
+
"Compile: `xanots export ./index.ts --out bundle.json` (or `writeBundle(app, path)`",
|
|
834
|
+
"from `@xanots/sdk/node` in code). The default export must be the `Xano` registry. The entry must be an",
|
|
835
|
+
'ES module (XanoTS defs are ESM-only): set `"type": "module"` in the nearest',
|
|
836
|
+
'package.json or name the entry `.mts`. `npm init -y` writes `"type": "commonjs"`,',
|
|
837
|
+
'which fails with a "must be ES modules" error until you switch it to module.',
|
|
838
|
+
"",
|
|
839
|
+
"Set `canonical` on every `apiGroup`. The engine mints the URL token server-side, so",
|
|
840
|
+
"without one a group's client paths are unresolvable until a lock exists: the bundle",
|
|
841
|
+
"exports fine and `xanots paths` / `getPath()` then fail on the very queries it just",
|
|
842
|
+
"built. An explicit `canonical` resolves them from the source alone.",
|
|
843
|
+
"",
|
|
844
|
+
"Build warnings: `export()` prints the shapes that deploy clean and then do the wrong",
|
|
845
|
+
"thing (a `bulk.update` zero-filling omitted columns, an `ignoreEmpty` on an empty",
|
|
846
|
+
"operand, a `ref()` nothing binds). Each has a legitimate use, so each only warns \u2014",
|
|
847
|
+
"but nothing fails on a message no one reads, so in CI and in unattended agent builds",
|
|
848
|
+
"pass `--strict` (`emitBundle(app, { strict: true })` / `app.export({ strict: true })`):",
|
|
849
|
+
"every warning becomes a hard failure. Same bundle bytes either way.",
|
|
850
|
+
"",
|
|
851
|
+
"Identity: object guids derive from `(type, name)`, so renames change identity.",
|
|
852
|
+
"`xanots export <entry> --lock` freezes every guid + api-group/toolset canonical in a lock",
|
|
853
|
+
"file written BESIDE THE ENTRY FILE \u2014 `xano/xano.lock` for the standard scaffold, NOT the",
|
|
854
|
+
"project root \u2014 which you commit (auto-read once present; CI guard `--frozen-lock` fails",
|
|
855
|
+
"instead of changing the lock). Fix-up subcommands:",
|
|
856
|
+
"`xanots lock rename <kind> <old> <new>` (kind = payloadKey or table/api_group),",
|
|
857
|
+
"`xanots lock prune <entry-file> [keys\u2026] --yes` \u2014 finding orphans RUNS the entry's module scope",
|
|
858
|
+
"(env assertions included); `--no-verify --yes <kind:name>\u2026` prunes named keys with no evaluation",
|
|
859
|
+
"and no orphan check,",
|
|
860
|
+
"`xanots lock adopt <live-bundle.json> [--yes]` \u2014 all accept `--lock=<path>`.",
|
|
861
|
+
"Programmatic use: call `seedLockOverrides(readLockFile(path))` BEFORE importing",
|
|
862
|
+
"any def module \u2014 references bake guids at import time, so late seeding is a",
|
|
863
|
+
"silent no-op (`resetLockOverrides` exists for tests).",
|
|
864
|
+
"Development workflow: opt in EARLY \u2014 run `xanots export ./xano/index.ts --lock` once and COMMIT",
|
|
865
|
+
"`xano.lock` beside the entry file; every later export then keeps identities",
|
|
866
|
+
"stable across renames and environments. To rename an object: rename in code,",
|
|
867
|
+
"export (stderr prints the exact fix-up), run `xanots lock rename <kind> <old>",
|
|
868
|
+
"<new>`, export again \u2014 the original guid is emitted under the new name, so the",
|
|
869
|
+
"engine renames in place instead of delete+create. Taking over an existing",
|
|
870
|
+
"workspace: `xanots lock adopt <its-packageExport.json>` first, then export.",
|
|
871
|
+
"",
|
|
872
|
+
"## Deploy",
|
|
873
|
+
"",
|
|
874
|
+
"`xanots init` \u2192 `xanots deploy` \u2192 URL. Each `deploy` runs the same compile",
|
|
875
|
+
"pipeline as `export` (honoring `xano.lock`), then imports the result into a live",
|
|
876
|
+
"environment and prints its URL.",
|
|
877
|
+
"\u26A0 Every deploy is a FULL REPLACE: it clears that environment's workspace \u2014 objects",
|
|
878
|
+
"AND records \u2014 before importing. The blast radius is a disposable environment, not a",
|
|
879
|
+
"production workspace, but confirm with the user before the first run.",
|
|
880
|
+
"",
|
|
881
|
+
"**Two destinations, and the choice changes more than the target.**",
|
|
882
|
+
"",
|
|
883
|
+
"- `--dest ephemeral` (DEFAULT) \u2014 a NAMED, workspace-scoped, auto-expiring tenant",
|
|
884
|
+
" (~1h; `--expires-hours` 1\u201372 at create time). The active one is tracked in",
|
|
885
|
+
" `./.xano/ephemeral.json`, so deploying again REFRESHES it and the URL is unchanged;",
|
|
886
|
+
" if it expired or was swept, a fresh one is created and the new URL is called out.",
|
|
887
|
+
" `--static` puts the frontend ON THE EPHEMERAL, so backend and frontend share one",
|
|
888
|
+
" disposable environment.",
|
|
889
|
+
" \u26A0 Only the BACKEND URL survives a refresh: the replace clears static hosting too,",
|
|
890
|
+
" so `--static` publishes a NEW host every run and the previous URL stops serving.",
|
|
891
|
+
"- `--dest sandbox` \u2014 your single throwaway tenant, no expiry. `--static` puts the",
|
|
892
|
+
" frontend on your OWN (parent) workspace instead, because the sandbox tenant does",
|
|
893
|
+
" not serve static hosting.",
|
|
894
|
+
"- `xanots release` promotes to your INSTANCE workspace and MERGES, not replaces:",
|
|
895
|
+
" adds/updates what you define, deletes nothing, writes no rows. Destruction is",
|
|
896
|
+
" opt-in per flag, previewed + confirmed, and can drop a table WITH its rows.",
|
|
897
|
+
" NOT flag-gated: removing a column from a schema destroys that column's data",
|
|
898
|
+
" (previewed against live). `--prune` deletes only what `xano.lock` records this",
|
|
899
|
+
" project released, and refuses without a lock.",
|
|
900
|
+
"",
|
|
901
|
+
"Nothing from a DEPLOY is written back into `xano.lock` (an ephemeral/sandbox is a",
|
|
902
|
+
"separate workspace, so its identities must not pollute yours). The one write-back is",
|
|
903
|
+
"`release --replace`, which mints fresh identities in the workspace the lock describes:",
|
|
904
|
+
"it re-pins the lock from the rebuilt workspace, because otherwise the next release",
|
|
905
|
+
"matches nothing and duplicates every object. Deploying an ENTRY",
|
|
906
|
+
"FILE still updates the local lock via the shared compile step, exactly as `export`",
|
|
907
|
+
"does \u2014 only when a lock exists or `--lock` is passed.",
|
|
908
|
+
"",
|
|
909
|
+
"**Frontend wiring.** `--static <dir>` injects the DEPLOYED env's backend URL as",
|
|
910
|
+
"`window.XANO_HOST` into EVERY html document in the build, before the app bundle runs,",
|
|
911
|
+
"so the frontend needs no rebuild to target an env. Every document, not just the root:",
|
|
912
|
+
"a prerendered build serves a different one per route, and a route without the global",
|
|
913
|
+
"renders fine while every call goes to the wrong origin. Read it at runtime with a build-time",
|
|
914
|
+
"fallback:",
|
|
915
|
+
" const HOST = (typeof window !== 'undefined' && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;",
|
|
916
|
+
"In LOCAL DEV there is no injected global, so the fallback is what answers: set",
|
|
917
|
+
"`VITE_XANO_HOST` in a `.env.local` beside `.env.example` at the PROJECT ROOT. The",
|
|
918
|
+
"scaffold's vite config sets `envDir` there (its `root` is `frontend/`, and Vite",
|
|
919
|
+
"resolves `.env` files against `root`) \u2014 without it the var reads as undefined, the",
|
|
920
|
+
"host falls back to '', and every call 404s off the dev server.",
|
|
921
|
+
'\u26A0 It is INJECTED in bracket form \u2014 `window["XANO_HOST"]="\u2026"` \u2014 so verifying a deploy',
|
|
922
|
+
"by grepping `window.XANO_HOST` matches nothing and reads as a failed inject. Grep the",
|
|
923
|
+
"bare `XANO_HOST` token.",
|
|
924
|
+
"\u26A0 `xanots validate` ignores the deploy login and reads `XANO_VALIDATE_INSTANCE` /",
|
|
925
|
+
"`XANO_VALIDATE_TOKEN` (+ optional `XANO_VALIDATE_WORKSPACE_ID`) from the environment.",
|
|
926
|
+
"**Displaying a stored file.** A file column comes back as `{ path, name, type, size,",
|
|
927
|
+
"meta, access, url }`. \u26A0 Do NOT use its `url`: on a tenant-scoped environment that field",
|
|
928
|
+
"addresses the instance host WITHOUT the `/tenant/<name>` segment and 404s, silently \u2014",
|
|
929
|
+
"as a broken `<img>`, with every API assertion still passing. Build the URL from `path`",
|
|
930
|
+
"instead: `fileUrl(row.avatar, HOST)` (exported from `@xanots/sdk`) returns `null` for",
|
|
931
|
+
"an absent file and is correct on an ephemeral and an instance workspace alike.",
|
|
932
|
+
"\u26A0 A static host serves these files verbatim, so everything injected is PUBLIC \u2014 base",
|
|
933
|
+
"URLs and publishable keys only, never secrets. Secrets go in backend env, read",
|
|
934
|
+
"server-side via `env(name)`.",
|
|
935
|
+
"",
|
|
936
|
+
"**Full CLI surface:** `xanots <command> --help` lists every command, flag, and",
|
|
937
|
+
"default; the shipped `manifest.json` carries the same in its `cli` array. This doc",
|
|
938
|
+
"does not duplicate it \u2014 it covers what you must know to AUTHOR a workspace.",
|
|
939
|
+
"**Recommended style:** reach statements through the `s` namespace",
|
|
940
|
+
"(`s.db.add`, `s.math.add`, \u2026) \u2014 one discoverable, tab-completable surface. The",
|
|
941
|
+
"flat factory aliases (`dbAdd`, `dbQuery`, `setVar`, `mathAdd`, \u2026) are exported",
|
|
942
|
+
"and identical in output; prefer `s.*` in new code so examples stay consistent.",
|
|
943
|
+
""
|
|
944
|
+
);
|
|
945
|
+
lines.push(
|
|
946
|
+
"## Gotchas",
|
|
947
|
+
"",
|
|
948
|
+
"Non-obvious authoring rules:",
|
|
949
|
+
"",
|
|
950
|
+
"- **No callback builder.** Flat def-objects + `register*`, not",
|
|
951
|
+
" `workspace(w => w.table(...))`. `workspace(name)` returns a named `new Xano()`;",
|
|
952
|
+
" tables are `table({ schema: { col: f.text() } })`.",
|
|
953
|
+
"- **Foreign key is `f.tableRef(table)`, not `ref`.** `ref(name)` references a",
|
|
954
|
+
" stack variable (a value); `f.tableRef` is the column constructor.",
|
|
955
|
+
"- **Reference-helper picker:** `ref` = stack var (`as:` output), `inp` = input,",
|
|
956
|
+
' `col` = table column (in `db.query` `where`), `auth("id")` = the caller,',
|
|
957
|
+
" `c.*` = a constant. Pick by what you're pointing at.",
|
|
958
|
+
"- **`s.api.call` / `s.task.call` / `s.trigger.call` / `s.workflow_test.call` are",
|
|
959
|
+
" WORKFLOW-TEST ONLY.** Outside a `workflowTest({...})` stack the engine cannot reach",
|
|
960
|
+
" the target, so one in a query/function/task deploys clean and then answers the first",
|
|
961
|
+
" real request with `ERROR_FATAL: <Type> does not exist: <type>:<n>` \u2014 and not per",
|
|
962
|
+
" host kind, the same call fails identically from a function a query runs.",
|
|
963
|
+
" `export()` refuses them. `s.function.call`, `s.tool.call`, `s.middleware.call` and",
|
|
964
|
+
" `s.addon.call` run anywhere, as does `s.function.run` (the ordinary function",
|
|
965
|
+
" invocation). To share logic between endpoints, put it in a `defineFunction` and",
|
|
966
|
+
" `s.function.run` it from both.",
|
|
967
|
+
"- **`expect.*` is a unit-test assertion; `s.expect.*` is a workflow-test statement.**",
|
|
968
|
+
" A statement's `mock` is keyed by TEST NAME; an undeclared name throws. See `llms/tests.md`.",
|
|
969
|
+
"- **Drilling into a maybe-null `db.get` result 500s \u2014 use `ref(path, { safe: true })`.**",
|
|
970
|
+
' `db.get` binds `null` on a no-match, but a nested `ref("owner.user_id")` resolves',
|
|
971
|
+
' `$owner.user_id` in one lookup and raises a runtime "Unable to locate var" (HTTP 500)',
|
|
972
|
+
" when `owner` is null \u2014 so an ownership/existence guard throws instead of failing",
|
|
973
|
+
' cleanly. Either guard existence first (`expr(ref("owner"), "!=", c.null())`,',
|
|
974
|
+
" or a `db.has`/`db.query`-count precondition), or drill with the null-safe opt-in:",
|
|
975
|
+
' `expr(ref("owner.user_id", { safe: true }), "=", auth("id"))` compiles through the',
|
|
976
|
+
" `get` filter and yields `null` (guard reads `false`) rather than 500ing.",
|
|
977
|
+
" Works inside `obj({...})` too \u2014 no per-member `s.set_var` hoist needed.",
|
|
978
|
+
" \u26A0 EXPRESSION and `obj()` operands ONLY \u2014 never a `db.*` match argument. `null`",
|
|
979
|
+
" is not a legal `fieldValue`/`id`, so a chained get (fetch the child row, then",
|
|
980
|
+
" fetch its parent to check the owner) fails with HTTP 400 `Missing param:",
|
|
981
|
+
" field_value` one statement BEFORE the guard \u2014 the same net effect as the 500,",
|
|
982
|
+
" with an internal-sounding status the caller should never see. Only an id",
|
|
983
|
+
" matching no row reaches it, so it deploys clean. In the chained case guarding",
|
|
984
|
+
" existence first is MANDATORY, not a stylistic alternative: precondition on",
|
|
985
|
+
' `expr(ref("ticket"), "!=", c.null())`, then drill WITHOUT `safe` (the base is',
|
|
986
|
+
" known non-null by then). `export()` warns on a safe ref in that position.",
|
|
987
|
+
" \u26A0 That is for a base that EXISTS and may be null. If the base binds nothing at all",
|
|
988
|
+
" (a typo), `{ safe: true }` turns the 500 into a silent `null` and buries the bug \u2014",
|
|
989
|
+
" `export()` warns on a `ref()` whose base segment no `as` in that stack binds, with a",
|
|
990
|
+
" did-you-mean; fix the name instead.",
|
|
991
|
+
"- **DB reads are field-match, not `where`-expr.** `db.get`/`db.edit`/`db.del`/",
|
|
992
|
+
" `db.has`/`db.patch` match one field: `{ fieldName, fieldValue }` (`fieldName`",
|
|
993
|
+
" defaults to the PK `id`). Only `db.query` takes a `where`/`additionalWhere`",
|
|
994
|
+
" `expr(...)`; writes (`db.add`/`db.edit`/`db.add_or_edit`) take a `row`/`data`.",
|
|
995
|
+
" `db.get_by_id` is the exception to the field-match rule \u2014 it is the dedicated",
|
|
996
|
+
" get-by-primary-key statement and takes `{ table, id }`, NOT `fieldValue`.",
|
|
997
|
+
" Signatures: `llms/statements-data.md`.",
|
|
998
|
+
"- **Single-field only \u2014 no composite match.** These ops match exactly ONE field;",
|
|
999
|
+
" there is no two-field form (the engine's by-field lookup takes a single",
|
|
1000
|
+
" predicate). For a `(a, b)` existence/fetch \u2014 e.g. dedupe a `(habit, date)`",
|
|
1001
|
+
' check-in \u2014 use `db.query({ where: [expr(col("habit"), "=", ...), expr(col("date"), "=", ...)], as })`',
|
|
1002
|
+
" (a `where` array is ANDed) and branch on the result, rather than pushing the",
|
|
1003
|
+
" check to the client.",
|
|
1004
|
+
"- **System columns are auto-injected.** `id` + `created_at` are prepended to",
|
|
1005
|
+
" every table (`system: true` by default); declaring them by hand is redundant.",
|
|
1006
|
+
' `id` is an `int` PK by default; pass `idType: "uuid"` on the table for a uuid key.',
|
|
1007
|
+
" Both are valid targets wherever a column name is accepted \u2014 `db.query` `sort`/",
|
|
1008
|
+
" `output`, a `db.get`/`edit`/`del` `fieldName`, etc. (the column-name type is",
|
|
1009
|
+
' `keyof schema | "id" | "created_at"`), and both appear in `InferRow<typeof table>`.',
|
|
1010
|
+
"- **Seed a table's starting rows with `table({ seed })`.** `seed` takes rows",
|
|
1011
|
+
" typed against the table's schema as a WRITE shape (a column without",
|
|
1012
|
+
" `required: true`, and the system columns, may be omitted; `null` needs",
|
|
1013
|
+
' `nullable: true`) \u2014 inline (`seed: [{ name: "\u2026" }]`), a FILE',
|
|
1014
|
+
' (`seed: seedFile("./seed.json", import.meta.url)`; path resolves against the DECLARING',
|
|
1015
|
+
' file), or a thunk (`seed: () => import("./seed.json")`, async ok, `.default` unwrapped).',
|
|
1016
|
+
" Inline rows are TYPED against the schema at compile time; a `seedFile`/thunk seed is",
|
|
1017
|
+
" NOT \u2014 the JSON is opaque to the typechecker, and the `xanots export`/`deploy` CLI",
|
|
1018
|
+
" validates it instead, naming the row index, the offending column, and the known",
|
|
1019
|
+
" columns. \u26A0 Prefer `seedFile` for a file: a thunk's `import()` sits in",
|
|
1020
|
+
" YOUR module, so a bundler emits the JSON as a served chunk. NEITHER is a secrecy",
|
|
1021
|
+
" boundary \u2014 a bundler that inlines the asset ships them either way. Never put secrets",
|
|
1022
|
+
" in `seed`. `deploy --static` REFUSES a build carrying internal/sensitive seed values;",
|
|
1023
|
+
" `--allow-seed-in-static` overrides for deliberately public demo credentials.",
|
|
1024
|
+
" Deploy is a full replace, so re-deploying re-seeds",
|
|
1025
|
+
" cleanly (no duplication). Omit `id` and rows auto-number `1..N` (int PK) or take",
|
|
1026
|
+
" a stable derived uuid (uuid PK); supplying `id` pins it (engine preserves it,",
|
|
1027
|
+
" resets an int sequence past the max). All-or-nothing \u2014 mixing explicit and",
|
|
1028
|
+
" omitted `id` throws. A `system:false` PK is the author's to supply. Pinning is",
|
|
1029
|
+
" `seed`-only \u2014 `s.db.bulk.add` DROPS `id` unless `allowIdField: true`.",
|
|
1030
|
+
"- **`use_xdo` storage mode.** Workspace setting (`registerWorkspace({ use_xdo })`,",
|
|
1031
|
+
" default `false`) controlling whether fields are stored as JSON under the `xdo`",
|
|
1032
|
+
" column (`true`, adds a `gin(xdo)` index) or as real columns (`false`, no gin).",
|
|
1033
|
+
" Tables inherit it; override per-table with `table({ useXdo })`. Resolved at",
|
|
1034
|
+
" `export()`, so the workspace and tables can be registered in any order.",
|
|
1035
|
+
"- **Self-referencing tables** need the bare-name form: inside `tweets`'s own",
|
|
1036
|
+
' schema, write `f.tableRef("tweets", { type: "int" })` \u2014 the `const tweets`',
|
|
1037
|
+
` handle isn't assigned yet, so the handle form throws "used before declaration".`,
|
|
1038
|
+
"- **Same-name siblings collide.** Object guids derive from `(type, name)` and",
|
|
1039
|
+
" ignore a query's `verb`, so a `GET` and `POST` both named `posts` clash;",
|
|
1040
|
+
" `export()` throws on the collision \u2014 give them DISTINCT names (`posts` and",
|
|
1041
|
+
" `posts/create`; the verb still carries the semantics). A distinct explicit",
|
|
1042
|
+
" `guid` on each clears that throw but is NOT a lasting fix: a lock entry is",
|
|
1043
|
+
" keyed by `(type, name)` too and cannot hold two guids, so `export --lock`",
|
|
1044
|
+
" refuses the pair \u2014 and a committed lock is the recommended workflow.",
|
|
1045
|
+
" `export()` warns on a same-name pair even unlocked. An explicit `guid` is",
|
|
1046
|
+
" for pinning identity across a rename, not for sharing a name.",
|
|
1047
|
+
"- **`export()` vs `emitBundle()` vs `writeBundle()`:** `writeBundle(app, path)`",
|
|
1048
|
+
" writes it to disk; `export()` returns the bundle object;",
|
|
1049
|
+
" `emitBundle()` returns the pretty JSON string. All three run the SAME build-time",
|
|
1050
|
+
" checks, INCLUDING seed validation of a literal `seed: [...]` array (row shape,",
|
|
1051
|
+
" unknown column, coercion, enum membership, the all-or-nothing `id` rule). A",
|
|
1052
|
+
' DEFERRED seed \u2014 a thunk (`() => import("./seed.json")`) or `seedFile()` \u2014 needs an',
|
|
1053
|
+
" await or the filesystem, so it is materialised and checked only by the",
|
|
1054
|
+
" `xanots export`/`deploy` CLI path. Seed rows are never emitted into the bundle",
|
|
1055
|
+
" either way; only `deploy` ships them. The `node:fs` writers",
|
|
1056
|
+
" (`writeBundle`/`writeArtifact`) and lock-file I/O import from `@xanots/sdk/node`,",
|
|
1057
|
+
" NOT the browser-safe `@xanots/sdk` entry (which a frontend can import query",
|
|
1058
|
+
" defs from to use `getPath()`/`InferInput` with no Node built-ins in the bundle).",
|
|
1059
|
+
" The compiler machinery (per-kind `encode*`, the registries, the bundle serializer,",
|
|
1060
|
+
" the lock model) is on `@xanots/sdk/internal` and is never needed to author.",
|
|
1061
|
+
"- **Client bundle size / tree-shaking.** `@xanots/sdk` is `sideEffects: false` and pulls",
|
|
1062
|
+
" no Node built-ins, so a bundler drops unused SDK exports. But importing a **def** for its",
|
|
1063
|
+
" `getPath()`/`verb`/`getUrl()`/`getChannel()` also pulls whatever its `stack` references:",
|
|
1064
|
+
" the `s.*`/`c.*` factory CALLS run at module load to BUILD it. Types are free.",
|
|
1065
|
+
" \u26A0 A FLOOR \u2014 **~289 kB minified (~57 kB gzipped)** for the FIRST def; splitting modules",
|
|
1066
|
+
" never removes it. The floor is the RUNTIME, not the def: a second def, or a much richer",
|
|
1067
|
+
" one, adds ~1 kB. So the cost is paid by importing ANY def at all, and reducing what a",
|
|
1068
|
+
" def does will not reduce it.",
|
|
1069
|
+
" Fix: `xanots paths <entry> --emit xano/routes.gen.ts` (`routes` is an accepted alias) \u2014 verbs, paths, and sockets as",
|
|
1070
|
+
' plain data importing NOTHING, still compile-checked: `routePath("blog/{slug}", { slug })`,',
|
|
1071
|
+
' `channelPath("rooms/{room_id}", { room_id })`, `socketUrl("chat", baseUrl)` (tenant base',
|
|
1072
|
+
" URLs lifted to `wss://h/ws/<tenant>:<canonical>`). A rename is a type error, not a 404.",
|
|
1073
|
+
"- **Intra-workspace imports use `.js` specifiers** (`../tables/links.js`), not",
|
|
1074
|
+
" extensionless \u2014 the defs compile under `moduleResolution: bundler`. Add the `.js`.",
|
|
1075
|
+
"- **Verifying a def outside a bundler.** Inside a bundler (Vite/webpack) importing a",
|
|
1076
|
+
" query def to read `getPath()`/`verb` works directly. To spot-check from Node, run a REAL",
|
|
1077
|
+
' file with `tsx <file.ts>` **from inside the project root** \u2014 not `tsx -e "import \u2026"`',
|
|
1078
|
+
" (its CJS-preparse mis-resolves the package `exports` map \u2192 ERR_PACKAGE_PATH_NOT_EXPORTED),",
|
|
1079
|
+
" and not bare `node file.ts` (chokes on the `.js`-specifier intra-workspace imports the",
|
|
1080
|
+
" xanots CLI's own loader resolves). Running from outside the project root also breaks",
|
|
1081
|
+
" the `@xanots/sdk` specifier resolution.",
|
|
1082
|
+
"- **A helper returning `Statement[]` widens the stack and kills `InferResponse`.**",
|
|
1083
|
+
" Factoring a repeated guard into `function requireX(): Statement[]` and spreading it",
|
|
1084
|
+
' (`...requireX("d")`) drops the stack\'s TUPLE type, so EVERY `ref()`/`as` in that stack \u2014',
|
|
1085
|
+
" including ones declared after the spread \u2014 resolves to `unknown` and the query's",
|
|
1086
|
+
" response types as `StackTupleWidened`. Nothing fails at the helper: the error surfaces",
|
|
1087
|
+
" wherever the response is consumed, often a frontend package's typecheck. Fix: return",
|
|
1088
|
+
" `statements(s.a(...), s.b(...))` \u2014 a const-generic identity export whose tuple survives",
|
|
1089
|
+
" the spread. A helper that builds its array in a LOOP cannot be a tuple; declare",
|
|
1090
|
+
" `responseShape` on the query there. See `llms/statements-runtime.md`.",
|
|
1091
|
+
"- **Block specials nest a `body`, not a `stack`.** `s.for`/`s.foreach`/",
|
|
1092
|
+
" `s.while`/`s.switch`/`s.try_catch`/`s.db.transaction`/`s.expect.to_throw`",
|
|
1093
|
+
" take their sub-stack as `body` (`try`/`catch`/`finally` for `try_catch`);",
|
|
1094
|
+
" `s.group(body)` and `s.util.post_process(body)` take it **positionally**.",
|
|
1095
|
+
" `s.for` is **count-bounded** (`{ as, count, body }`), not from/to. See the",
|
|
1096
|
+
" authored signatures in `llms/statements-data.md`.",
|
|
1097
|
+
"- **MCP servers & agents are distinct root kinds** that both persist under the",
|
|
1098
|
+
" `toolset` payload key (so a same-name pair collides). `mcpServer({...})` exposes",
|
|
1099
|
+
" tools over MCP (auth is per-tool \u2014 no server-level gate); `agent({...})` carries a",
|
|
1100
|
+
" typed `llm` block \u2014 and so may an `mcpServer`, since the two are ONE stored",
|
|
1101
|
+
" object distinguished by `type`. Their `tools` take a `tool()` handle (or name), resolved to the",
|
|
1102
|
+
" tool's guid like the call family; a raw numeric `id` is an escape hatch.",
|
|
1103
|
+
"- **`task.schedule` is an array** of `{ startsOn, freq?, repeatEnabled?, endsOn?, endsEnabled? }`",
|
|
1104
|
+
" (`ScheduleDef[]`), not a single `{ type, value }`. `freq` is seconds; `startsOn`/",
|
|
1105
|
+
' `endsOn` are timestamp strings validated at encode time \u2014 `"2026-01-01T00:00:00Z"`, or',
|
|
1106
|
+
' the space-separated `"2026-01-01 00:00:00+0000"` a pulled workspace carries. Never',
|
|
1107
|
+
" epoch numbers, and never zoneless: the engine stores the string verbatim without",
|
|
1108
|
+
" checking it, so one it cannot parse deploys clean and the task then never fires.",
|
|
1109
|
+
"- **`get_input`/`get_raw_input` read the whole payload**, not one named input",
|
|
1110
|
+
" (args are `{ as?, encoding?, excludeMiddleware? }` \u2014 no `name`). For a single",
|
|
1111
|
+
' input use `inp("name")`.',
|
|
1112
|
+
'- **`f.password()` defaults to `access: "internal"`, so `db.get` does NOT return it.**',
|
|
1113
|
+
' A login stack that reads `ref("u.password")` after a plain `db.get` fails at runtime',
|
|
1114
|
+
" with `Unable to locate var: u.password` \u2014 the column is simply absent from the row.",
|
|
1115
|
+
' Name it in the read\'s `output` to pull it: `s.db.get({ table: users, fieldName: "email",',
|
|
1116
|
+
' fieldValue: inp("email"), output: ["id", "email", "password"], as: "u" })`, then',
|
|
1117
|
+
" `s.security.check_password`. `output` OVERRIDES column visibility \u2014 it is the only way to",
|
|
1118
|
+
" read an `internal` column, and `export()` warns when a stack reads one a `db.get` did not",
|
|
1119
|
+
" return.",
|
|
1120
|
+
"- **Build regex-filter patterns with `c.regex(body, flags?)`, never `c.text`.**",
|
|
1121
|
+
" The regex filters (`regex_test`/`regex_match`/`regex_replace`/\u2026) are pattern-piped",
|
|
1122
|
+
" PHP `preg_*`: the piped value is the PATTERN and must be delimiter-wrapped. A bare",
|
|
1123
|
+
' `c.text("^[^@\\s]+@...$")` is an invalid pattern that matches *nothing* for every',
|
|
1124
|
+
" input, so a precondition on it silently rejects all values (valid ones included).",
|
|
1125
|
+
' `c.regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "i")` wraps + escapes it for you (accepts a',
|
|
1126
|
+
" JS `RegExp` too: `c.regex(/^\u2026$/i)`), and `withFilters` throws on a bare `c.text`",
|
|
1127
|
+
" pattern pointing here \u2014 from ANY position in the chain, so a normalizer in front",
|
|
1128
|
+
' (`withFilters(c.text("^hi"), fl.trim(), fl.regex_test(...))`) is refused too; nothing',
|
|
1129
|
+
" upstream adds the delimiters. `s.expect.to_match`'s `value` is the same PATTERN slot,",
|
|
1130
|
+
" refused the same way; a `ref`/`inp` pattern is passed through untouched, either place.",
|
|
1131
|
+
" Better still, prefer a native typed input",
|
|
1132
|
+
" (`input.email({...})`) over hand-rolled validation.",
|
|
1133
|
+
"- **Compose a rule set as SIBLINGS, not a folded chain.** `and(...rules)` takes any",
|
|
1134
|
+
" number of terms and encodes flat; `rules.reduce((acc, r) => and(acc, r))` nests one",
|
|
1135
|
+
" container per rule, which costs quadratic bytes (512 terms: 394 KiB flat, 21 MiB",
|
|
1136
|
+
" folded) and is refused past 128 levels. Mixed joins: `and(or(...anyOf), ...allOf)`.",
|
|
1137
|
+
'- **Declare inputs with `input.<type>()`, read them with `inp("name")`.** `inp()`',
|
|
1138
|
+
" resolves ONLY against this def's own `input` block \u2014 a value produced earlier in",
|
|
1139
|
+
' the stack is `ref("var.field")`, not `inp("field")`. An undeclared name fails at',
|
|
1140
|
+
" runtime with `Unable to locate input` (`export()` warns).",
|
|
1141
|
+
"- **Don't take a password through `input.password` on login \u2014 it double-hashes.**",
|
|
1142
|
+
" An `f.password()` column hashes on write, and `input.password` *also* hashes the",
|
|
1143
|
+
" submission on bind, so `s.security.check_password` compares two different hashes",
|
|
1144
|
+
" and a correct password always fails (`ok:false` on a found row). Take the submitted",
|
|
1145
|
+
" password as `input.text()` on both signup and login and pass the plaintext straight",
|
|
1146
|
+
" to `check_password` (which does the comparison hash itself).",
|
|
1147
|
+
"- **Agents authenticate with env vars \u2014 never `xanots login`.** `login` blocks on a",
|
|
1148
|
+
" browser consent no agent can complete. Set `$XANO_INSTANCE_URL` + `$XANO_WORKSPACE_ID`",
|
|
1149
|
+
" + `$XANO_META_TOKEN` and run `deploy`/`release` directly: no disk, no rotation, so it",
|
|
1150
|
+
" survives repeated runs. It outranks every other credential (`--config` and",
|
|
1151
|
+
" `$XANO_REFRESH_TOKEN` included, each named on stderr when displaced), and setting SOME",
|
|
1152
|
+
" of the three is a hard error \u2014 a misspelled secret must not fall through to whatever",
|
|
1153
|
+
' credential the runner has. As a file that triple is `{ "type": "token",',
|
|
1154
|
+
' "instance_base_url": \u2026, "workspace_id": <n>, "meta_api_token": \u2026 }`. The older',
|
|
1155
|
+
" `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works but ROTATES: single-use.",
|
|
1156
|
+
"- **Event-driven objects fire on an EPHEMERAL, not in the sandbox.** A `task`",
|
|
1157
|
+
" (scheduled), an `mcpServer`, and every trigger \u2014 `tableTrigger` included \u2014 run normally",
|
|
1158
|
+
" on an ephemeral env, which is `deploy`'s DEFAULT destination. So test an event-driven",
|
|
1159
|
+
" design (screen-on-insert, cron cleanup, MCP tool call) by deploying it and letting it",
|
|
1160
|
+
" run.",
|
|
1161
|
+
" \u26A0 Under `--dest sandbox` they import cleanly but their stacks NEVER execute, and there",
|
|
1162
|
+
" is no way to fire one manually \u2014 an insert on a bound table does not run its",
|
|
1163
|
+
" `tableTrigger`, and the design silently does nothing. Only synchronously-invoked objects",
|
|
1164
|
+
" (queries, functions, and the agents an endpoint calls with `s.ai.agent.run`) run there.",
|
|
1165
|
+
" If you must stay on the sandbox, verify the logic out of band: factor the body into a",
|
|
1166
|
+
" `defineFunction` (or a callable `query`) and invoke it directly \u2014 a `tableTrigger` that",
|
|
1167
|
+
" screens a row on insert should delegate to a function a `query` can also call via",
|
|
1168
|
+
" `s.function.run`, and you assert against that.",
|
|
1169
|
+
'- **Zero-based numeric keys make `c.obj` a LIST.** `c.obj({ "0": "a" })` evaluates to',
|
|
1170
|
+
' `["a"]`: a numeric key IS an index in the engine\'s data model, so keys that are exactly',
|
|
1171
|
+
" `0..n-1` come back as a list with HTTP 200 and no error. Write `c.array([...])` when you",
|
|
1172
|
+
' want the list, or prefix the keys (`"k0"`) when you want an object. A non-zero-based',
|
|
1173
|
+
' numeric key (`{ "2": \u2026 }`) survives as a key. Export warns.',
|
|
1174
|
+
"- **To match every row, omit `where` \u2014 never pass a constant.** `where: c.bool(true)` is",
|
|
1175
|
+
' not "no filter": a bare scalar literal states no condition and the engine reads the',
|
|
1176
|
+
' resulting `context.search` as garbage, so it throws. An absent `where` is how "every',
|
|
1177
|
+
' row" is spelled. A raw `Value` `where` stays the escape hatch for a clause built',
|
|
1178
|
+
' elsewhere \u2014 `inp("clause")`, `ref("built_where")`, or `c.text("id > 0")`.',
|
|
1179
|
+
"- **An `auth()`-keyed limiter collapses on any host with no caller identity \u2014 at every**",
|
|
1180
|
+
' **tier.** `s.redis.ratelimit({ key: withFilters(c.text("rl:"), fl.concat(auth("id"))) })`',
|
|
1181
|
+
" is the per-user form, but `auth()` is `null` on a public query, a task, or a function,",
|
|
1182
|
+
" and every caller then shares one bucket. Attaching it once at",
|
|
1183
|
+
" `apiGroup({ middleware })` or the workspace tier is the tempting DRY move and inherits",
|
|
1184
|
+
" the same collapse onto every member endpoint. Export warns, naming the tier.",
|
|
1185
|
+
""
|
|
1186
|
+
);
|
|
1187
|
+
const objectKinds = [];
|
|
1188
|
+
objectKinds.push("## Object kinds", "");
|
|
1189
|
+
objectKinds.push("Author with the factory, register on the Xano instance, lands under the payload key. Each line ends with a one-liner on what the primitive is.", "");
|
|
1190
|
+
objectKinds.push(
|
|
1191
|
+
"\u26A0 Composing a workspace from independently-authored modules: `register*` takes its defs however they arrive, but `modules.flatMap((m) => m.tables)` does NOT typecheck. `Array.prototype.flatMap` binds its element type to the FIRST element, so every later module's tables are checked against that one table's schema \u2014 the error compares two unrelated column names and mentions neither `flatMap` nor the cause. `.concat()` collapses the same way. Two spellings work: pass an array LITERAL (`registerTables([...a.tables, ...b.tables])` \u2014 spreads are fine, TypeScript infers the union across every element at once), or annotate the module array with the wide aliases `AnyTableDef` / `AnyQueryDef` / `AnyFunctionDef` / `AnyAddonDef`, exported for exactly the four def types whose generics can collapse this way. Widening the array costs nothing: the typing you need lives on the `table()`/`query()` handle you hold and pass to `s.db.*`, and is never read back off the registered array.",
|
|
1192
|
+
""
|
|
1193
|
+
);
|
|
1194
|
+
for (const k of m.objectKinds) {
|
|
1195
|
+
if (k.subKinds && k.subKinds.length > 0) {
|
|
1196
|
+
for (const sub of k.subKinds) {
|
|
1197
|
+
if (sub.legacy) continue;
|
|
1198
|
+
objectKinds.push(
|
|
1199
|
+
`- ${k.kind} (${sub.objType}): \`${sub.authorFactory}\` \u2192 \`Xano.${k.registerMethod}\` \u2192 payload \`${k.payloadKey}\` \u2014 ${sub.description}`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
} else {
|
|
1203
|
+
objectKinds.push(`- ${k.kind}: \`${k.authorFactory}\` \u2192 \`Xano.${k.registerMethod}\` \u2192 payload \`${k.payloadKey}\` \u2014 ${k.description}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
objectKinds.push("");
|
|
1207
|
+
const kindsCore = [
|
|
1208
|
+
"## Object def shapes",
|
|
1209
|
+
"",
|
|
1210
|
+
"The def-object passed to each factory. `?` = optional. `input` is keyed by",
|
|
1211
|
+
"input name (`input.<type>(opts?)`); `stack` is `Statement[]` (`s.*`); `response`",
|
|
1212
|
+
"is a `ResponseDef` (see **Responses** below). Object identity is `guid?` \u2014",
|
|
1213
|
+
"omit it and it derives from `name` (set it to survive a rename).",
|
|
1214
|
+
"",
|
|
1215
|
+
"- `defineFunction({ name, guid?, description?, docs?, workspace?, input?, stack?, response?, tests? })`",
|
|
1216
|
+
"- `query({ name, verb, apiGroup?, guid?, auth?, input?, stack?, response?, responseType?, apiEnabled?, disabled?, cache?, description?, docs?, tests?, example? })`",
|
|
1217
|
+
' - `verb`: `"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"` (required), UPPERCASE. Anything else \u2014 most often a lowercase `"post"` \u2014 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.`',
|
|
1218
|
+
" - `apiGroup`: an `apiGroup()` def handle (or its name) \u2014 binds by guid, stable across syncs. Raw numeric `apiGroupId?` is the escape hatch and wins if both given.",
|
|
1219
|
+
' - `auth`: `false` (no auth) or an auth-table id; `responseType`: `"standard" | "stream"` (default `standard`) \u2014 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.',
|
|
1220
|
+
' - `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 \u2014 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 \u2014 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.',
|
|
1221
|
+
' - **Client recipe:** `q.getPath({ params: { slug: "hello" } })` \u2192 `/api:<canonical>/blog/hello` \u2014 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 \u2014 `%2e` counts \u2014 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.',
|
|
1222
|
+
"- `apiGroup({ name, guid?, canonical?, description?, docs?, swagger?, apiGroupEnabled?, documentation?, cors? })` \u2014 a query container; register it and bind queries to it via their `apiGroup`.",
|
|
1223
|
+
" - `cors?`: `{ mode?, allowOrigins?: string[], allowHeaders?: string[], allowCredentials?, maxAge?, allowMethods?: { get?, post?, put?, patch?, delete?, head? } }`.",
|
|
1224
|
+
' - `mode?`: `"default"` (the default) | `"custom"` | `"disabled"`, lowercase \u2014 a fourth value THROWS at export (`apiGroup()` itself does not check), because Xano neither rejects nor blanks it: it DROPS THE WHOLE API GROUP on import, so the deploy succeeds and every query in the group 404s. \u26A0 Every OTHER field applies only under `"custom"`: `"default"` serves a FIXED permissive policy (any origin, `allow-headers: *`, `allow-credentials: true`, `max-age: 86400`) and ignores the block, so setting `maxAge`/`allowCredentials`/`allowHeaders` alone changes nothing. `"disabled"` sends no CORS headers at all, so every browser call fails.',
|
|
1225
|
+
' - \u26A0 Under `"custom"`, `allowOrigins` is matched as EXACT strings (scheme+host+port, no wildcard or subdomain expansion) and `"*"` is compared as a literal origin \u2014 it matches NOTHING. An unmatched origin gets no `access-control-*` headers at all, so the call fails in the browser on a missing `access-control-allow-origin` while export, deploy and the preflight all look fine. Name each origin, or use `mode: "default"` for any-origin. `allowMethods` gates the REAL response too: a verb left off gets no CORS headers back even though its preflight passes. Export warns on an empty origin list, a `"*"` entry, and a policy with no method enabled.',
|
|
1226
|
+
'- `defineFunction`/`query`/`apiGroup` above cover the queries+tables core; the four below are the "reach past that" primitives (tasks, workflow tests, middleware, tools). Agents and MCP servers are the same family and live in `llms/kinds-agent-mcp.md`. Same envelope conventions (`guid?`, `description?`, `docs?`, `tags?`, `history?`) unless noted.',
|
|
1227
|
+
"- `task({ name, guid?, description?, docs?, datasource?, active?, tags?, history?, schedule?, stack?, middleware? })` \u2014 a scheduled background job (function-like `stack`, no `input`/`response`).",
|
|
1228
|
+
' - `schedule?`: a `ScheduleDef[]` (NOT a single object) \u2014 `{ startsOn, freq?, repeatEnabled?, endsOn?, endsEnabled? }`. `startsOn`/`endsOn` are **timestamp strings** validated at encode time \u2014 `"2026-01-01T00:00:00Z"`, or the space-separated `"2026-01-01 00:00:00+0000"` a pulled workspace carries \u2014 never epoch numbers, and never zoneless; `freq` is the repeat interval **in seconds** (default `86400` = daily); `endsOn` present \u21D2 the schedule has an end. `endsEnabled?` defaults to that and is recovery-only \u2014 state it to reproduce a stored schedule that remembers an end date with the gate OFF. Fires on an ephemeral; does NOT fire in the sandbox (see Gotchas).',
|
|
1229
|
+
'- `workflowTest({ name, guid?, description?, docs?, datasource?, active?, tags?, stack? })` \u2014 an end-to-end test. NO `input`/`response`: `.call` something with an `as`, then assert on that var \u2014 `s.function.call({ fn, input, as: "r" })`, `s.expect.to_equal({ expr: ref("r"), value: c.int(42) })`. `s.expect.*` belongs here \u2014 it is not inert elsewhere (a failure 500s the request), so treat one in a query/function/task as a mistake to remove. `active?` defaults `true`; chain tests with `s.workflow_test.call({ workflowTest: <def handle> })`.',
|
|
1230
|
+
' - `datasource?`: **the trap.** Default `""` is an EMPTY datasource (recommended), not "no datasource". Any non-empty name makes the engine CLONE it before EVERY run \u2014 against production-sized data, slow enough to fail the run. `"live"` warns at compile time; other names don\'t.',
|
|
1231
|
+
"- `middleware({ name, guid?, description?, docs?, resultStrategy?, exceptionPolicy?, tags?, history?, input?, stack?, response?, responseShape?, tests? })` \u2014 a pre/post interceptor (function-like `stack`); attach it via a host's `middleware: { pre, post }`. \u26A0 `input` ENCODES but an ATTACHED middleware never has it bound \u2014 the host request binds its own inputs, so `inp()` inside pre/post fails at runtime with `Unable to locate input` and a declared default does not stand in (`export()` warns). Read the request body with `s.util.get_all_input` instead; it yields a `{ type, vars }` envelope. `s.middleware.call` is the one path that DOES bind the declared map.",
|
|
1232
|
+
' - `resultStrategy?`: `"merge" | "replace"` (default `merge`) \u2014 how the middleware `response` folds into the host\'s.',
|
|
1233
|
+
' - `exceptionPolicy?`: `"silent" | "rethrow" | "critical"` (default `"rethrow"` \u2014 a throw ABORTS the request and surfaces the authored error/status, which is what a guard wants). `"silent"` swallows the throw and lets the request through, so a guard set to it is NOT enforced \u2014 use it only for advisory middleware. `"critical"` is `"rethrow"` plus skipping the `post` chain.',
|
|
1234
|
+
"- `tool({ name, guid?, description?, instructions?, docs?, enabled?, tags?, history?, input?, stack?, response?, responseShape?, middleware? })` \u2014 a function-like operation (`input`/`stack`/`response`) that a toolset (MCP server or agent) exposes. Register it, then reference it from a toolset's `tools`.",
|
|
1235
|
+
"### Responses",
|
|
1236
|
+
"",
|
|
1237
|
+
"The `response?` field (on functions, queries, tools, middleware, and",
|
|
1238
|
+
"response-bearing triggers) maps to the stored `result[]`:",
|
|
1239
|
+
"",
|
|
1240
|
+
"- `ResponseDef = Value | Record<string, Value>`.",
|
|
1241
|
+
'- A single `Value` \u2192 one unnamed result item: `response: ref("rows")`.',
|
|
1242
|
+
'- A record \u2192 one named item per key: `response: { user: ref("u"), token: ref("t") }`.',
|
|
1243
|
+
"- Omitted \u2192 empty `result[]` (no response body).",
|
|
1244
|
+
"",
|
|
1245
|
+
"### Expressions (`expr`)",
|
|
1246
|
+
"",
|
|
1247
|
+
"`expr(left, op, right)` builds the comparison used by every condition/`where`",
|
|
1248
|
+
"surface \u2014 `s.conditional`/`s.while` `when` (incl. each `elif` branch), and",
|
|
1249
|
+
"`db.query` `where`/`additionalWhere` (and the search triggers) \u2014 one shared tree.",
|
|
1250
|
+
"",
|
|
1251
|
+
"- `op`: `=`, `!=`, `>`, `<`, `>=`, `<=` (JS aliases `==` `===` `!==` are accepted and normalized).",
|
|
1252
|
+
'- `left`/`right` are `Value`s \u2014 `col("x")` (a table column), `ref`, `inp`, `auth(...)`, or `c.*`.',
|
|
1253
|
+
"- For the full operator set (`in`/`like`/`ilike`/`between`/`contains`/`overlaps`/`@>`/`~`/`search`/\u2026)",
|
|
1254
|
+
" use `cmp(left, op, right, { ignoreEmpty? })`; compose nested boolean logic with `and(...)`/`or(...)`.",
|
|
1255
|
+
"- \u26A0 The wider `cmp` operators are DATABASE-only (`where`, table view filter, db trigger",
|
|
1256
|
+
" `search`). A RUNTIME condition \u2014 `s.conditional`/`elif`, `s.while`, `s.precondition`,",
|
|
1257
|
+
" `array.*` `if` \u2014 takes the `expr` set only; the rest are refused at build time because",
|
|
1258
|
+
" deployed they fail the request with `Invalid op: <op>` on that branch, usually a guard.",
|
|
1259
|
+
' Spell membership out: `or(expr(x, "=", a), expr(x, "=", b))`.',
|
|
1260
|
+
"- A condition/`where` accepts a single `expr(...)`/`cmp(...)`, an `and()`/`or()` group, an array of",
|
|
1261
|
+
" those (ANDed), or (for `where`) a raw `Value`. `s.conditional`/`s.while`/`s.switch`, `db.query`,",
|
|
1262
|
+
" `precondition`, and the `array.*` predicates all take the same TREE shape (operators per above).",
|
|
1263
|
+
"- \u26A0 `mixed(a, { or: b }, { and: c })` reproduces a container whose terms do NOT all join the",
|
|
1264
|
+
" same way \u2014 the editor allows it, so pulled workspaces contain it. **Do not author it.** The",
|
|
1265
|
+
" stored form does not record the grouping, and the two places it can appear disagree: a",
|
|
1266
|
+
" branch (`s.conditional`/`s.while`/`precondition`) folds terms strictly left to right, so",
|
|
1267
|
+
" `a OR b AND c` is `(a OR b) AND c`, while a `db.query` filter applies the engine's",
|
|
1268
|
+
" AND-before-OR precedence and selects `a OR (b AND c)`. Write `and(or(a, b), c)` or",
|
|
1269
|
+
" `or(a, and(b, c))` \u2014 each says one reading in every context. Pulls report these as",
|
|
1270
|
+
" `ambiguous-condition`.",
|
|
1271
|
+
"- A **filtered** operand (`withFilters(...)`) works inline in any condition/`where` (conditional,",
|
|
1272
|
+
' while, `db.query`/addon, \u2026) \u2014 e.g. `cmp(withFilters(col("title"), fl.trim()), "=", inp("q"))`.',
|
|
1273
|
+
'- e.g. `db.query({ table: posts, where: expr(col("author"), "=", auth("id")), as: "rows" })`.',
|
|
1274
|
+
""
|
|
1275
|
+
];
|
|
1276
|
+
const kindsAgentMcp = [
|
|
1277
|
+
"- `mcpServer({ name, guid?, description?, instructions?, docs?, enabled?, canonical?, spec?, tags?, history?, tools?, llm?, output? })` \u2014 an MCP toolset. `llm?`/`output?` are the same blocks `agent()` takes and are usually absent: an MCP server and an agent are ONE stored object distinguished by `type`, so a server that carries LLM settings can say so. Returns a handle with `getPath()`/`getUrl(baseUrl)` for the Streamable-HTTP endpoint \u2014 `getUrl` is NOT idempotent, a `baseUrl` already carrying an `/x2/mcp/<\u2026>/stream` path (an earlier result) THROWS, so resolve ONCE from the instance base URL. Fires on an ephemeral; does NOT fire in the sandbox (see Gotchas).",
|
|
1278
|
+
" - `tools?`: a `ToolsetToolEntry[]`. Pass the `tool()` HANDLES directly (`tools: [saveNote]`), like every other collection in the SDK; use the `{ tool, enabled?, auth? }` wrapper only when a tool needs `enabled: false` or per-tool `auth`. `auth` names an auth **table** (a `table({ auth: true })` handle or its name) \u2014 Xano's ONLY MCP auth surface (per-tool; there is no server-level gate). An entry that names no tool (no handle, no `id`) THROWS at export rather than emitting the `id: 0` null reference it used to; a deliberate raw `id: 0` warns and is carried through, so a pulled workspace still round-trips.",
|
|
1279
|
+
"- `agent({ name, guid?, description?, docs?, enabled?, canonical?, tags?, history?, llm, tools?, output? })` \u2014 an LLM orchestrator. No top-level `instructions`/`prompt`/`spec` \u2014 the prompt lives under `llm`. Invoke from a stack with `s.ai.agent.run({ agent, args })`.",
|
|
1280
|
+
' - `llm` (REQUIRED): typed provider settings, a discriminated union on `type` (`"xano-free" | "anthropic" | "openai" | "google-genai"`). Shared fields: `systemPrompt?`, `maxSteps?` (default `5`), and `prompt?` XOR `messages?` (genuinely exclusive: both is a type error and throws \u2014 the engine stores ONE `prompt_type`, so one would be dropped); plus provider fields (`apiKey?`, `model?`, `temperature?`, `reasoningEffort?`, \u2026). String fields accept Twig placeholders \u2014 `{{ $args.x }}` for run inputs (the `args` of `s.ai.agent.run`), `{{ $env.NAME }}` for env vars.',
|
|
1281
|
+
" - `tools?`: same `ToolsetToolEntry[]` as `mcpServer` \u2014 bare `tool()` handles, or a `{ tool, enabled?, auth? }` wrapper when one needs `enabled`/`auth`.",
|
|
1282
|
+
' - `output?`: `{ schema: Record<string, input.*>, enabled? }` \u2014 structured-output schema. `schema` is a named-field record authored with the `input.*` catalog, exactly like a `defineFunction`/`query` `input:` map (the stored `structuredOutputsSchema` is the same wire shape as function inputs). e.g. `output: { schema: { priority: input.enum(["low","high"]), summary: input.text() } }`. When you pass the agent *handle* to `s.ai.agent.run({ agent })`, `.result` is typed straight from this schema \u2014 no `resultShape` witness needed (the shape is declared once). `resultShape` remains only to override that, or to type an agent referenced by bare name.',
|
|
1283
|
+
' - **Run + read recipe (`s.ai.agent.run`):** bind the run to a var (`s.ai.agent.run({ agent, args, as: "run" })`) \u2014 it produces a rich envelope, and the completion is at **`.result`**. Read one structured field with a dotted ref (`response: ref("run.result.priority")`) or the whole completion (`ref("run.result")`, typed from `output.schema`); persist it in a later step the same way (`s.set_var({ name, value: ref("run.result.summary") })`). `args` is a plain object of run inputs (`{ topic: inp("topic") }` \u2014 raw literals are fine, e.g. `{ max_steps: 3 }`) surfaced to the agent as `{{ $args.topic }}`. Tool-call / per-step data (when tools ran) rides `.toolCalls` / `.steps` on the same envelope \u2014 both optional, absent or empty when no tools executed.'
|
|
1284
|
+
];
|
|
1285
|
+
const kindsRealtime = [
|
|
1286
|
+
"- **Realtime** \u2014 the only three-level chain: `realtimeServer` owns `realtimeChannel`s, which own `realtimeMessage` handlers. Pass the HANDLE, not a name (a channel path is unique only within its server).",
|
|
1287
|
+
" - `realtimeServer({ name, guid?, description?, enabled?, canonical?, tags?, history? })` \u2014 the container.",
|
|
1288
|
+
" - `enabled` defaults to **false** \u2014 the one `enabled` in the SDK that does.",
|
|
1289
|
+
" - An enabled server with no active channel still refuses the handshake.",
|
|
1290
|
+
" - `realtimeChannel({ name, server, guid?, description?, active?, input?, anonymousClients?, presence?, publish?, conversation?, delivery?, rateLimit?, tags?, history? })`",
|
|
1291
|
+
' - `name` is a PATH (`"lobby"`, `"rooms/{room_id}"`); `input` types its `{param}` segments, NOT the payload. Every `{param}` MUST have a matching input, and that input must be a SCALAR and not a list (`json`/`object`/`array: true` have no URL form), or `realtimeChannel()` THROWS. `required: true` is NOT checked and is not needed \u2014 segment counts must match, so the segment is always present at join. Name charset as query (`A-Za-z0-9_-/{}`, max 200), and so is `tool`; `realtimeMessage` is NARROWER \u2014 no `/` or `{}`.',
|
|
1292
|
+
" - Matching is STRICT: a literal segment beats a param (`rooms/lobby` and `rooms/{room_id}` coexist); segment counts must be EQUAL (`rooms/{room_id}` does NOT match `rooms/42/edit`); literals are CASE-SENSITIVE; an empty segment is REJECTED, not collapsed (a leading/trailing/doubled `/` matches nothing). `getChannel()` throws on an empty or slash-bearing param for that reason.",
|
|
1293
|
+
" - An INACTIVE channel reports the same error as a nonexistent one \u2014 deactivating leaks nothing.",
|
|
1294
|
+
" - `anonymousClients` is gated TWICE: the server admits the connection, then the channel admits the join. Setting it here alone is not enough.",
|
|
1295
|
+
' - `publish?: { who?: "nobody"|"anyone"|"authenticated", direct? }` \u2014 `who` defaults to `nobody`: nobody can publish until you set it. `direct` (default false) lets a client address ANOTHER CLIENT via a frame\'s `options.socketId`, and is checked BEFORE `who`.',
|
|
1296
|
+
" - `conversation?: { enabled?, limit?, ttl? }` \u2014 the client-visible TRANSCRIPT replayed to a joiner (distinct from `history`, which is execution history). \u26A0 `limit` DEFAULTS TO 0 AND 0 MEANS OFF: `{ enabled: true }` alone records nothing and replays nothing, silently. `ttl` is an IDLE expiry of the WHOLE transcript, refreshed by every write (an active channel never ages out; a silent one loses all of it at once) \u2014 NOT a per-message age cap.",
|
|
1297
|
+
' - `delivery?: { guarantee?: "at_most_once"|"at_least_once", perRecipient? }` \u2014 `perRecipient` is independent of the guarantee, is a NO-OP unless the channel declares a `deliver` trigger, and costs a stack PER RECIPIENT PER MESSAGE. Per-viewer redaction needs BOTH HALVES \u2014 this flag AND an active `deliver` trigger bound to the channel \u2014 and with either missing the payload is delivered UNCHANGED to everyone; `export()` warns on each half alone.',
|
|
1298
|
+
" - `rateLimit?: { messagesPerMinute? }` \u2014 0 = unlimited, checked BEFORE the handler runs. A COST guardrail, not a security control: an anonymous client is bucketed per CONNECTION (reconnecting resets it), and it fails OPEN when its store is down.",
|
|
1299
|
+
" - `realtimeMessage({ name, channel, server?, guid?, description?, active?, auth?, deliverTo?, input?, middleware?, stack?, response?, responseShape?, history?, disabled?, tags? })` \u2014 the invocable unit (the realtime analogue of a query).",
|
|
1300
|
+
" - `input` types the message PAYLOAD. `server` is required only when `channel` is a bare path.",
|
|
1301
|
+
' - `deliverTo?`: `"channel"` (default) | `"sender"` | `"others"` | `"explicit"`. \u26A0 `"explicit"` still delivers to NOBODY \u2014 nothing selects recipients from inside a handler, and `s.realtime.publish` (which originates an event INTO a channel) is not a substitute.',
|
|
1302
|
+
' - Only `"channel"`/`"others"` fan out AND are written to the `conversation` transcript \u2014 a `"sender"` response is invisible to every future joiner.',
|
|
1303
|
+
' - **Both input surfaces read as ordinary inputs:** `inp("body")` for a payload field, `inp("room_id")` for the channel\'s `{room_id}`. No session lookup, no frame parsing.',
|
|
1304
|
+
" - A path param is bound ONCE at join and read from the connection thereafter, never from the frame \u2014 a sender cannot claim a room it did not join. The same values reach a channel `join`/`leave` trigger's stack.",
|
|
1305
|
+
" - `s.realtime.get_session({ as })` \u2014 the CALLER's realtime session for the current frame. FLAT shape:",
|
|
1306
|
+
' - `authenticated` bool \xB7 `client_id` text (the AUTHED ROW ID as text, `""` anonymous) \xB7 `dbo_id` int (the auth TABLE\'s id \u2014 NOT the user\'s row id; `0` anonymous \u2014 to look the caller up use `client_id`. `dbo_id` is an int in the same position and typechecks, so a gate that keys on it finds no user and refuses EVERYONE) \xB7 `socket_id` int (transport id) \xB7 `channel` text (resolved path, `""` in a server trigger) \xB7 `params` object (bound path params, `{}` when none \u2014 `ref("session.params.room_id")`) \xB7 `extras` object \xB7 `opened_at` decimal.',
|
|
1307
|
+
" - Works in a realtime MESSAGE stack and in CHANNEL and SERVER trigger stacks; off that path it degrades to an anonymous session.",
|
|
1308
|
+
' - For a path param prefer `inp("room_id")`. Reach for the session when you need the CONNECTION (identity/extras) \u2014 "who is this sender" on an anonymous-client channel.',
|
|
1309
|
+
" - \u26A0 THREE UNRELATED THINGS ARE CALLED A CLIENT ID: `session.client_id` (app-facing identity), `session.socket_id` (transport), and a frame's `options.client_id` (the at_least_once CURSOR handle). Conflating the first and last breaks at_least_once for anonymous clients.",
|
|
1310
|
+
" - `s.realtime.publish({ server, channel, data, message?, authTable?, authId? })` \u2014 the PUSH direction: originate a server-authored event onto a channel from ANY stack, no client frame first.",
|
|
1311
|
+
" - `server` is the handle or its NAME (resolved by name, not guid); `channel` is the FILLED-IN path (`channel.getChannel({ room_id: 42 })`), never the template \u2014 a constant still carrying `{param}` THROWS at author time, and a constant `server`/`channel` naming nothing this workspace registers WARNS at export.",
|
|
1312
|
+
' - A PER-ROW path whose id is only known at runtime is built as a value, not with `getChannel()` (which needs the id at author time): `withFilters(c.text("rooms/"), fl.concat(ref("room.id")))`, or `s.set_var` + `s.text.prepend`. A computed `channel`/`server` \u2014 a `ref`/`inp`, or a constant carrying a filter chain \u2014 is left alone by the export check.',
|
|
1313
|
+
" - DELIVERY-ONLY \u2014 fanned out as-is; does NOT invoke a `realtimeMessage()` handler even when `message` names one (a channel `deliver` trigger still runs).",
|
|
1314
|
+
" - SERVER-AUTHORITATIVE \u2014 bypasses `publish.who`, which governs CLIENTS. Authorize in your own stack.",
|
|
1315
|
+
" - \u26A0 FAIL-SOFT \u2014 a missing/disabled server or dead bus is swallowed engine-side, so a mis-targeted publish is SILENT with no result to check.",
|
|
1316
|
+
" - `authTable`/`authId` are ASSERTED attribution on the frame \u2014 not a credential, nothing validates them.",
|
|
1317
|
+
" - **Client recipe (derive, never hardcode):**",
|
|
1318
|
+
' - `server.getUrl(baseUrl)` \u2192 `wss://<host>/ws/<canonical>` \u2014 accepts the `https://\u2026` instance base URL and normalizes the scheme. `channel.getChannel({ room_id: 42 })` \u2192 `"rooms/42"`, the path that goes in a frame\'s `channel` field. Both throw rather than guess. A canonical is minted by `xanots export <entry> --lock`.',
|
|
1319
|
+
" - Auth is a bearer token passed as the websocket SUBPROTOCOL: `new WebSocket(url, token)`. No token = an anonymous client, admitted only where `anonymousClients: true`.",
|
|
1320
|
+
' - Frames are JSON `{ action: "join"|"leave"|"broadcast"|"ack"|"ping"|"presence", channel, type?: <message name>, payload?, options?, id? }`. You must `join` before you may `broadcast`, and the server\'s context is ready only a moment after `open` \u2014 an immediate first frame is refused.',
|
|
1321
|
+
" - `options` is `{ socketId?, client_id?, channel? }` \u2014 `socketId` addresses another client directly (needs `publish.direct`), `client_id` is the at-least-once cursor handle, and `options.channel` WINS over a top-level `channel`.",
|
|
1322
|
+
' - \u26A0 KEEP THE SOCKET ALIVE: an idle connection is REAPED after ~10 minutes. A LISTEN-ONLY client (a feed or dashboard that joins and rarely publishes) MUST send `{ action: "ping" }` (answered `pong`) or any frame periodically or it silently drops.',
|
|
1323
|
+
" - Server frames: `join` (ack `{ joined: true, params }`, + `cursor`/`resumed` on at_least_once) \xB7 `message` \xB7 `replay` \xB7 `broadcast` \xB7 `presence_full`|`presence_join`|`presence_leave` \xB7 `conversation_start`|`conversation_end` (replayed frames flagged `conversation: true`) \xB7 `pong` \xB7 `ack` \xB7 `error`.",
|
|
1324
|
+
" - \u26A0 `broadcast` is a RECEIPT to the sender, not a delivery confirmation: `payload.delivered_local` counts recipients on the ANSWERING NODE ONLY, not the channel. It also carries `id` on at_least_once and `dropped: true` when the handler returned null.",
|
|
1325
|
+
" - `error` carries `payload.message`, plus `code`/`limit`/`retry_after` when rate limited. `rate_limited` is the ONLY code \u2014 do NOT switch on `code`.",
|
|
1326
|
+
" - An `error` is a per-frame refusal, NOT a disconnect \u2014 EXCEPT a failed handshake and a REFUSED `connect` trigger, which each send one and then CLOSE with code 4401.",
|
|
1327
|
+
" - **Tenant instances (isolated DB):** a tenant's realtime objects live in the TENANT's database, so BOTH halves of a client must name the tenant.",
|
|
1328
|
+
" - Socket: `server.getUrl(base, { tenant })` \u2192 `/ws/<tenant>:<canonical>`. \u26A0 A bare canonical on a tenant host resolves against the INSTANCE workspace instead.",
|
|
1329
|
+
" - That colon form is PECULIAR TO THE SOCKET. Every other tenant URL gives the tenant its OWN segment \u2014 the HTTP half of the same client is `https://<host>/tenant/<tenant>/api:<canonical>/\u2026`. NO request header is required for either.",
|
|
1330
|
+
" - Because the shapes differ, `getUrl` TRANSLATES a tenant base URL instead of concatenating: pass the `https://<host>/tenant/<name>` that `sandbox details` prints (and that deploy injects as `window.XANO_HOST`) and the tenant is LIFTED into the socket form. So `getUrl(window.XANO_HOST)` needs no `{ tenant }`, and a CONFLICTING `{ tenant }` alongside it throws.",
|
|
1331
|
+
" - \u26A0 `getUrl`/`socketUrl` are NOT idempotent \u2014 a `baseUrl` that already carries a `/ws/<\u2026>` path (an earlier result of either) THROWS. Resolve ONCE from the http(s) base; pass that result to `new WebSocket`, never back in as a base.",
|
|
1332
|
+
" - Still pass `{ tenant }` explicitly for a tenant on its OWN DOMAIN \u2014 the hostname carries it for HTTP, but there is nothing in the URL for the socket to lift.",
|
|
1333
|
+
" - \u26A0 Tokens are tenant-scoped (audience `<tenant>:<license>`, not the bare license), so one minted through the instance workspace is REJECTED by a tenant's realtime server \u2014 authenticate and dial through the same tenant.",
|
|
1334
|
+
" - **Presence frames** (a `presence: true` channel only):",
|
|
1335
|
+
" - `presence_full` carries `payload.members` \u2014 an ARRAY holding the WHOLE roster, including the receiving client. `presence_join`/`presence_leave` carry a single `payload.member`.",
|
|
1336
|
+
" - A member is `{ id, dbo_id, authenticated, extras, joined_at }`: `id` the auth row id as a string (`\"\"` anonymous), `dbo_id` the auth table's id (`0` anonymous), `extras` the connection's extras object, `joined_at` epoch SECONDS.",
|
|
1337
|
+
" - Render from `presence_full`, then apply the deltas. The roster counts MEMBERS, not connections (refcounted per identity \u2014 a second tab fires no second `presence_join`).",
|
|
1338
|
+
" - Join order: `join` ack \u2192 `presence_full` \u2192 (others get `presence_join`) \u2192 conversation replay \u2192 `replay` frames.",
|
|
1339
|
+
' - A joined client can re-request the snapshot any time with `{ action: "presence", channel }`, answered to the SENDER only. A socket that never joined is REFUSED \u2014 the roster is not readable without membership.',
|
|
1340
|
+
" - **Conversation frames \u2014 the transcript hydrates the client, so DO NOT build a hydration endpoint.**",
|
|
1341
|
+
' - On a `conversation` channel the replay is PUSHED automatically at join, unasked: `conversation_start` (`payload.count`) \u2192 the last `limit` messages, each a normal `action: "message"` frame carrying its ORIGINAL `type` and `payload` plus `conversation: true` and the original `ts` \u2192 `conversation_end`.',
|
|
1342
|
+
" - So the client needs NO fetch, no `GET /messages`, and no table read to paint the initial view. Render `message` frames identically either way; the backfill paints itself.",
|
|
1343
|
+
" - \u26A0 `{ enabled: true }` ALONE IS A NO-OP: `limit` defaults to 0, and 0 means RETAIN NONE (not retain everything), so the transcript is never written and never replayed, with no error. ALWAYS PASS `limit`.",
|
|
1344
|
+
" - The POST-HANDLER broadcast payload IS the stored transcript row \u2014 a handler must broadcast everything the UI needs to render a past message (author name, id, `created_at`). Nothing else is replayed.",
|
|
1345
|
+
' - Only `deliverTo` `"channel"`/`"others"` are RECORDED, so a `"sender"` response is invisible to every future joiner by construction.',
|
|
1346
|
+
" - The transcript is a capped ring (`limit`, `ttl`), not storage. Persist to a table only for durability, search, or reads BEYOND that window \u2014 never merely to hydrate a joiner.",
|
|
1347
|
+
' - **`delivery.guarantee: "at_least_once"` is a CLIENT CONTRACT, not just a channel setting.**',
|
|
1348
|
+
' - The client must ACK what it receives \u2014 `{ action: "ack", channel, id }`, confirmed by `{ action: "ack", channel, payload: { cursor } }`.',
|
|
1349
|
+
" - \u26A0 An ANONYMOUS client must ALSO send a durable `options.client_id` in its JOIN frame (once; later acks need not repeat it). WITHOUT one it has no cursor, its acks are SILENTLY IGNORED, and it degrades to at_most_once. An AUTHENTICATED client is keyed by identity and needs no `client_id`.",
|
|
1350
|
+
" - The missed gap arrives after join as `replay` frames, oldest-first, each with an `id` to ack.",
|
|
1351
|
+
' - DISTINCT from the conversation transcript: `conversation_*` is the SHARED "what was said before I arrived", `replay` is the PER-CLIENT "what I missed while disconnected". Both may be on.',
|
|
1352
|
+
" - How far back `replay` reaches is sized by `conversation.ttl` (here a REAL per-message age cut, and it BEATS `limit`), else `conversation.limit`, else 1000 \u2014 even on a channel with no transcript enabled.",
|
|
1353
|
+
" - **What a message handler RETURNS decides delivery, and the failure directions are NOT symmetric.**",
|
|
1354
|
+
" - A returned value fans out per `deliverTo` and becomes the transcript row.",
|
|
1355
|
+
" - Returning NULL delivers NOTHING \u2014 the supported way to veto a message (the sender is told `dropped: true`).",
|
|
1356
|
+
" - A payload REJECTED by the declared `input` also delivers nothing,; the detail goes ONLY to the sender.",
|
|
1357
|
+
" - \u26A0 But a handler that CRASHES FAILS OPEN: the sender's ORIGINAL, UNVALIDATED payload is broadcast to the channel unchanged. A handler doing redaction or authorization must NOT be the only thing between client input and subscribers.",
|
|
1358
|
+
""
|
|
1359
|
+
];
|
|
1360
|
+
const triggers = [
|
|
1361
|
+
"### Triggers",
|
|
1362
|
+
"",
|
|
1363
|
+
"**A trigger's `stack` is a callback \u2014 `stack: (t) => [...]`, not the plain",
|
|
1364
|
+
"`stack: []` array that `defineFunction`/`query`/`task` use.** That's the one",
|
|
1365
|
+
"shape that doesn't carry over from the other kinds: a trigger has no",
|
|
1366
|
+
"user-declared `input`, so its inputs are **implied by type** (fixed by Xano,",
|
|
1367
|
+
"not editable) and arrive through the typed **stack handle** `t` \u2014 you can't",
|
|
1368
|
+
"reference them without it. (Response-bearing types take `response: (t) =>",
|
|
1369
|
+
"ResponseDef` too.) `t` exposes exactly that trigger type's inputs; a wrong",
|
|
1370
|
+
"name is a compile error, not a runtime surprise. The seven trigger types are",
|
|
1371
|
+
"distinct root factories (not a namespace): `{tableTrigger, realtimeServerTrigger,",
|
|
1372
|
+
"realtimeChannelTrigger, mcpServerTrigger, agentTrigger, workspaceTrigger,",
|
|
1373
|
+
"errorTrigger}({ name, guid?, description?, active?, tags?, ... })`.",
|
|
1374
|
+
"",
|
|
1375
|
+
'- `tableTrigger({ name, table?, datasources?, actions?: {insert?,update?,delete?,truncate?}, stack })` \u2014 database/table trigger. `t.new` / `t.old` are the row **after** / **before** the change; `t.action` (`insert|update|delete|truncate`), `t.datasource`. Bind `table` to a `table()` handle and `t.new("col")` / `t.old("col")` are typed to that row (misspelled column = compile error). Nullability follows the enabled actions: insert \u2192 `old` is null, delete \u2192 `new` is null, update \u2192 both, truncate \u2192 neither. Config-only (no response).',
|
|
1376
|
+
"- `realtimeServerTrigger({ name, realtimeServer, actions?: {connect?,disconnect?}, stack?, response?, responseShape? })` \u2014 realtime SERVER lifecycle (a client connecting to / disconnecting from the server, not a message). Inputs: `t.action` (`connect|disconnect`), `t.realtime_server`, `t.client`. Bind `realtimeServer` to a `realtimeServer()` handle (or its name). `connect` GATES the connection \u2014 a denial sends an `error` and CLOSES the socket with code 4401 before it is ever ready, so it is a real front door, not an observer; same return shape as a channel `join` (`{ allowed: c.bool(true) }` or any truthy value admits, EMPTY/FALSY DENIES \u2014 INCLUDING a gating trigger with NO `response`, which returns nothing and so refuses every client). A CRASH DENIES too \u2014 the transport seeds a deny and keeps it on a throw. Both failure modes lock the door, so plan for a self-inflicted LOCKOUT (an unguarded drill into a null `db.get` raises \u2192 everyone refused), not a breach. Gating is OPT-IN: a server with no `connect` trigger accepts every connection. `disconnect` is OBSERVATIONAL (return ignored, throws swallowed \u2014 cleanup must always complete). Both are SERVER-scoped, so `s.realtime.get_session` works but carries no channel path and no bound params.",
|
|
1377
|
+
'- `realtimeChannelTrigger({ name, channel, actions?: {join?,leave?,deliver?}, stack?, response?, responseShape? })` \u2014 realtime CHANNEL lifecycle. Inputs: `t.action` (`join|leave|deliver`), `t.channel`, `t.client`. Bind `channel` to a `realtimeChannel()` handle \u2014 a bare path is NOT accepted (it is unique only within its server). The three actions have DIFFERENT postures, and the posture decides what the stack should return: `join` GATES the join (it runs BEFORE the client becomes a member, so a denial means it never sees a fan-out) \u2014 return `{ allowed: c.bool(true) }` (optional `reason` reaches the client) or any truthy value to admit, and an EMPTY OR FALSY RETURN DENIES, so a stack that just falls through \u2014 or a gating trigger with NO `response` \u2014 refuses everyone, and a CRASH DENIES too. That is the inverse of a normal message (a crashing message still delivers) and of `deliver` below (a gate that fails OPEN). `join`/`leave` bind the channel\'s typed path params as INPUTS, so `inp("room_id")` resolves and the gate decides per room; a SERVER connect/disconnect has no channel, the one place a path param cannot be read; `leave` is OBSERVATIONAL (return ignored, throws swallowed); `deliver` GATES delivery PER RECIPIENT \u2014 the per-viewer redaction tool and the most expensive action here (a stack per recipient per message), and it needs `delivery.perRecipient` on the channel to run at all \u2014 BOTH HALVES are required, so a `deliver` trigger on a channel without the flag NEVER RUNS and every subscriber receives the UNREDACTED payload (no error, no log line); `export()` warns on each half alone. **`deliver`\'s RETURN VALUES DO NOT READ LIKE A FILTER:** ONLY an explicit NULL drops the message for that recipient; an OBJECT replaces that recipient\'s payload; ANYTHING ELSE \u2014 INCLUDING `false`, `0`, `""` \u2014 DELIVERS IT UNCHANGED, as does a crash. So `return false` from a yes/no redaction check SENDS the message it was written to suppress \u2014 return null instead. The delivered payload arrives NESTED, so read `inp("payload").<field>`, and `t.client` is the SENDER while `s.realtime.get_session` describes the RECIPIENT this run is for.',
|
|
1378
|
+
'- `mcpServerTrigger({ name, mcpServer, stack?, response?, responseShape? })` / `agentTrigger({ name, agent, stack?, response?, responseShape? })` \u2014 toolset connection. Bind with the `mcpServer()`/`agent()` def handle (or its name) \u2014 it resolves to the toolset guid at export. Raw numeric `objId` is the escape hatch, rarely right: ids are assigned at import, so a handle passed to `objId` is a type error, and binding nothing deploys a trigger that never fires. Inputs: `t.toolset` (`t.toolset("name")`), `t.tools`. Response-bearing; the default stack copies `toolset`/`tools` into vars and returns them.',
|
|
1379
|
+
"- `workspaceTrigger({ name, actions?: {branch_live?,branch_merge?,branch_new?}, stack? })` \u2014 branch lifecycle. Inputs: `t.to_branch`, `t.from_branch`, `t.action`. Config-only.",
|
|
1380
|
+
'- `errorTrigger({ name, stack? })` \u2014 error-signature trigger. Inputs: `t.event` (`new|regression|fixed`), `t.id`, `t.signature`, `t.error` (`t.error("code")`/`t.error("message")`), `t.caller`, `t.statement`, `t.actor`, `t.count`, `t.first_seen`, `t.last_seen`, `t.fixed_at`. Config-only.',
|
|
1381
|
+
""
|
|
1382
|
+
];
|
|
1383
|
+
const filters = [];
|
|
1384
|
+
const fields = [];
|
|
1385
|
+
const values = [];
|
|
1386
|
+
values.push("## Values", "");
|
|
1387
|
+
for (const v of m.values.constructors) {
|
|
1388
|
+
if (v.legacy) continue;
|
|
1389
|
+
values.push(`- \`${v.name}${v.signature}\` \u2014 ${v.description}`);
|
|
1390
|
+
}
|
|
1391
|
+
values.push("", `Tags: ${m.values.tags.join(", ")}.`, "");
|
|
1392
|
+
fields.push("## Fields", "");
|
|
1393
|
+
fields.push(
|
|
1394
|
+
"Author table columns + function/API inputs with the typed catalog: `f.<type>(opts?)`",
|
|
1395
|
+
"for columns, `input.<type>(opts?)` for inputs. Common opts: `required`, `nullable`,",
|
|
1396
|
+
"`default`, `description`.",
|
|
1397
|
+
"**`nullable` defaults PER TYPE, matching the engine's own column-creation API**: `true`",
|
|
1398
|
+
"for `f.vector`, `f.uuid`, every `f.geo.*` and every file type (`f.image`/`f.video`/",
|
|
1399
|
+
"`f.audio`/`f.attachment`), `false` for everything else (text, int, decimal, bool, email,",
|
|
1400
|
+
"enum, json, object, password, date, tableRef). Pass `nullable` explicitly to override \u2014",
|
|
1401
|
+
"e.g. `f.geo.polygon({ nullable: false })`. This is why `f.vector(8)` deploys: the engine",
|
|
1402
|
+
"turns an empty default into SQL NULL only for a nullable column, so a non-null vector",
|
|
1403
|
+
"would reach PostgreSQL as `vector(8) not null default ''` and fail to create.",
|
|
1404
|
+
"**`f.geo.*` values are `{ type, data }`, not GeoJSON.** The same shape goes in and comes",
|
|
1405
|
+
'back: `{ type: "point", data: { lng, lat } }`, `{ type: "poly", data: [{ lng, lat }, \u2026] }`',
|
|
1406
|
+
"\u2014 `type` is the engine's abbreviation, and a polygon ring is closed for you. Raw WKT text",
|
|
1407
|
+
'(`c.text("POINT(1 2)")`) is accepted on write too, but a read never returns one.',
|
|
1408
|
+
"`methods` is a bind-time validator/transform pipeline whose",
|
|
1409
|
+
'valid names depend on the field type (below) \u2014 pass bare names (`"trim"`), the',
|
|
1410
|
+
'colon-form with args (`"min:8"`), or `{ name, arg }` for anything not listed.',
|
|
1411
|
+
"`f.json({children})` declares the nested shape stored INSIDE a json column \u2014 an ARRAY of",
|
|
1412
|
+
"`{name, type, methods?, children?}`, order-significant, distinct from the `FieldMap` that",
|
|
1413
|
+
"`f.object` takes positionally. Omit it for an unstructured json column.",
|
|
1414
|
+
"`f.enum(values)`/`f.vector(size)`/`f.object(children)`/`f.tableRef(table)` take a",
|
|
1415
|
+
"positional payload before opts \u2014 and still accept the standard `FieldOptions` after it",
|
|
1416
|
+
"(`f.enum([])`/`input.enum([])` are accepted, because the engine stores an enum whose",
|
|
1417
|
+
"options were never filled in \u2014 that is a pulled-workspace shape, not one to author; it",
|
|
1418
|
+
"brands the column `never`, which `InferRow` surfaces at read time as `undefined` \u2014 so",
|
|
1419
|
+
"code written against `never` is dead in a way `undefined` is not.)",
|
|
1420
|
+
"(e.g. `f.tableRef(users, { required: true })` \u2014 only `min`/`max` are listed as tableRef",
|
|
1421
|
+
"methods below, but `required`/`nullable`/`description`/\u2026 apply like any field.)",
|
|
1422
|
+
"An **OPTIONAL foreign key wants a `0` sentinel, not `nullable: true`.** `f.tableRef` stores",
|
|
1423
|
+
"an `int`, and a null in it is unqueryable: `null` is never a legal `fieldValue`/`id`, so",
|
|
1424
|
+
"`s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather",
|
|
1425
|
+
"than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for",
|
|
1426
|
+
'"not set yet" \u2014 `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and',
|
|
1427
|
+
"binds `null`, which is the answer the null was reaching for. `export()` warns on a literal",
|
|
1428
|
+
"`c.null()` in that slot.",
|
|
1429
|
+
"An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through",
|
|
1430
|
+
'any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:',
|
|
1431
|
+
'"vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see',
|
|
1432
|
+
"`s.db.query` \u2192 `eval`). Without that pairing the column stores and indexes but nothing",
|
|
1433
|
+
"queries it.",
|
|
1434
|
+
"`{ array: true }` makes any `f.*` scalar a **list column** \u2014 `f.text({ array: true })`",
|
|
1435
|
+
"surfaces as `string[]` in `InferRow<typeof table>` (the column analogue of `input.list`).",
|
|
1436
|
+
"A **column `default` must stay within the BMP** \u2014 a 4-byte character (codepoint > U+FFFF,",
|
|
1437
|
+
"e.g. an emoji) is mangled into invalid UTF-8 by the engine's default pipeline and is rejected",
|
|
1438
|
+
"at export rather than 500ing at deploy (Postgres `22021`); BMP defaults (accents, `\u20AC`, most",
|
|
1439
|
+
"CJK) are fine, or put the value on an `input.<type>({ default })`, applied at runtime bind.",
|
|
1440
|
+
"`input.*` mirrors `f.*` \u2014 every column type below is",
|
|
1441
|
+
"a legal input (scalars, files `input.image/video/audio/attachment`, `input.geo.*`,",
|
|
1442
|
+
"`input.vector(size)`, `input.tableRef(table)`, `input.object(children)`), plus",
|
|
1443
|
+
"`input.dbLink(table)` is the odd one: ONE entry that EXPANDS into one input per",
|
|
1444
|
+
'COLUMN of the linked table, so read them by column name (`inp("email")`), never by',
|
|
1445
|
+
'the entry\'s own name. `hidden: ["created_at"]` drops columns from that expansion.',
|
|
1446
|
+
"`input.list(element)` for arrays \u2014 wrap any element constructor, e.g.",
|
|
1447
|
+
"`input.list(input.text())` or `input.list(input.object({ id: f.int() }))`. Prefer the",
|
|
1448
|
+
"typed forms over `input.json()` when the shape is known.",
|
|
1449
|
+
"**Typed inputs validate/coerce on bind, before your stack runs** \u2014 so reach for the",
|
|
1450
|
+
"specific type instead of hand-rolling checks. `input.email({ required: true })` rejects a",
|
|
1451
|
+
'malformed address with a 400 (and trims; add `methods: ["lower"]` to downcase) \u2014 no',
|
|
1452
|
+
"`regex_matches` needed; `input.int`/`input.decimal`/`input.uuid`/`input.enum([...])`/`input.date`",
|
|
1453
|
+
"likewise reject or coerce bad input at the boundary. Drop to `input.text` + `s.precondition`",
|
|
1454
|
+
"only for rules no type expresses.",
|
|
1455
|
+
"\u26A0 `input.url` is NOT one of them \u2014 there is no engine `url` type, so it stores as `text`",
|
|
1456
|
+
"and validates NOTHING: a `javascript:`/`data:` URL type-checks, imports, and binds. It",
|
|
1457
|
+
"names intent and carries the `text` methods, nothing more. When the value gets navigated",
|
|
1458
|
+
"to, check the scheme in the stack. It is INPUT ONLY \u2014 there is no `f.url` column.",
|
|
1459
|
+
'\u26A0 `s.precondition`\'s `error` must be a TAGGED value \u2014 `c.text("\u2026")`, not a bare string.',
|
|
1460
|
+
'The engine falls back to the generic "Precondition failed." whenever it reads an empty or',
|
|
1461
|
+
"non-scalar message, and a bare string lands there, so the client never sees your text. The",
|
|
1462
|
+
"`error_type` \u2192 HTTP status mapping is correct either way; only the message is lost. The bare",
|
|
1463
|
+
"form stays accepted so a pulled workspace round-trips, not as a spelling to choose.",
|
|
1464
|
+
`\`error_type\` IS how a stack sets a response status: ${preconditionStatusLine()}.`,
|
|
1465
|
+
"Normalizing transforms run on bind too \u2014 put `trim`/`lower`/`upper` on the input's `methods`",
|
|
1466
|
+
'so `inp("name")` reads already-normalized; don\'t reroll `var $x = inp("name")|trim` in the stack.',
|
|
1467
|
+
""
|
|
1468
|
+
);
|
|
1469
|
+
for (const ft of m.fieldTypes) {
|
|
1470
|
+
const stored = ft.stored !== ft.name.replace(/^geo\./, "") ? ` (stored \`${ft.stored}\`)` : "";
|
|
1471
|
+
const methods = ft.methods.length ? ` \u2014 methods: ${ft.methods.join(", ")}` : "";
|
|
1472
|
+
const ns = ft.inputOnly ? "input" : "f";
|
|
1473
|
+
const only = ft.inputOnly ? " \u2014 INPUT ONLY (no `f.` form)" : "";
|
|
1474
|
+
fields.push(`- \`${ns}.${ft.name}\`${stored}${methods}${only}`);
|
|
1475
|
+
}
|
|
1476
|
+
fields.push("");
|
|
1477
|
+
filters.push("## Filters", "");
|
|
1478
|
+
filters.push(
|
|
1479
|
+
"Attach to a value with `withFilters(v, fl.name(...))` \u2014 the value `filters[]`",
|
|
1480
|
+
"pipeline. Filters are passed spread (canonical); the array form",
|
|
1481
|
+
"`withFilters(v, [fl.a(), fl.b()])` is also accepted. Every filter is typed:",
|
|
1482
|
+
"the ones below carry named args, and the rest take NO arguments \u2014 call them",
|
|
1483
|
+
"`fl.<name>()`, and passing an argument is a compile error.",
|
|
1484
|
+
"A typed filter's declared argument list is an EXACT count, not a floor: passing more",
|
|
1485
|
+
"than it lists THROWS, in the type and at runtime. The extra argument used to ride into",
|
|
1486
|
+
"the filter's arg list, where the engine either ignores it or fails opaquely on a live",
|
|
1487
|
+
'endpoint. `filter("name", \u2026)` is the untyped escape for a filter the catalog',
|
|
1488
|
+
"under-declares. Seven declared filters are exceptions the engine really does take more from, so they",
|
|
1489
|
+
"stay variadic: `concat` (trailing separator), `index_by` (trailing list flag), `get` (trailing variable",
|
|
1490
|
+
"map), `array_merge` and `array_merge_recursive` (any number of arrays), `jwe_encode` and",
|
|
1491
|
+
"`jwe_decode`. To send more than a zero-argument filter's `()` accepts, use the same",
|
|
1492
|
+
'`filter("name", \u2026)` escape. A filter with NO declared list takes nothing and is emitted',
|
|
1493
|
+
"`()` \u2014 except the variadic few listed under the catalog, which take arguments the",
|
|
1494
|
+
"catalog never declared and so are not arity-checked at all.",
|
|
1495
|
+
"A typed filter also accepts one object of NAMED arguments (`fl.add({ value: 1 })`); a key",
|
|
1496
|
+
"the filter does not declare throws rather than being dropped.",
|
|
1497
|
+
"A bare JS **scalar** \u2014 string, number or boolean \u2014 is accepted in ANY `fl.*` argument and",
|
|
1498
|
+
'wrapped as the constant you would have written by hand (`fl.get("a.b", 0)` encodes',
|
|
1499
|
+
'identically to `fl.get(c.text("a.b"), c.int(0))`). An object or array must still be built',
|
|
1500
|
+
"with `c.obj`/`c.array`. The arg types below name each argument's ENGINE type, not the JS",
|
|
1501
|
+
"type you may pass.",
|
|
1502
|
+
"Read-modify-write a column from its current value with the pipeline: to increment",
|
|
1503
|
+
"a counter you MUST `db.get` the row first, then pipe its bound value \u2014",
|
|
1504
|
+
'`col("clicks")` does NOT resolve to the stored value inside a `db.edit` `row`',
|
|
1505
|
+
"(it is `null`, so `fl.add(1)` computes `null + 1` and the engine aborts):",
|
|
1506
|
+
'`s.db.get({ table, fieldValue, as: "current" })` then',
|
|
1507
|
+
'`s.db.edit({ table, fieldValue, row: { clicks: withFilters(ref("current.clicks"), fl.add(c.int(1))) } })`.',
|
|
1508
|
+
"\u26A0 This read-modify-write is NOT atomic \u2014 two concurrent writers can both read the",
|
|
1509
|
+
"same value and one increment is lost. There is no dedicated atomic-increment",
|
|
1510
|
+
"statement, and one CANNOT be synthesized in the SDK (it would compile to this same",
|
|
1511
|
+
"`get` + `edit` pair). For a **concurrency-safe** counter, do the arithmetic in the",
|
|
1512
|
+
"database with a single `s.db.direct_query` UPDATE (`SET clicks = clicks + 1 WHERE \u2026`),",
|
|
1513
|
+
"which the DB applies atomically. Reserve the pipeline form for low-contention counters",
|
|
1514
|
+
"where a rare lost update is acceptable.",
|
|
1515
|
+
"\u26A0 `direct_query` needs the table's PHYSICAL Postgres name, which the typed surface",
|
|
1516
|
+
"does NOT expose: the engine derives a physical name from workspace + table ids (of the",
|
|
1517
|
+
"form `x<workspace_id>_<table_id>`, e.g. `x6_203970`), ids assigned at import \u2014 not knowable",
|
|
1518
|
+
"from a `table()` def (identity is a name + guid, not the numeric id), and `sql_name`",
|
|
1519
|
+
"persists empty. So the safe counter drops out of the typed surface: hardcode",
|
|
1520
|
+
"the physical name after inspecting the deployed table. A typed atomic path needs an",
|
|
1521
|
+
"engine change.",
|
|
1522
|
+
""
|
|
1523
|
+
);
|
|
1524
|
+
const argFilters = m.filters.filter((fl) => fl.args?.length);
|
|
1525
|
+
for (const fl of argFilters) {
|
|
1526
|
+
const sig = (fl.args ?? []).map((a) => {
|
|
1527
|
+
const type = a.enum?.length ? a.enum.map((m2) => JSON.stringify(m2)).join("|") : a.type;
|
|
1528
|
+
return `${a.name}${a.optional ? "?" : ""}: ${type}`;
|
|
1529
|
+
}).join(", ");
|
|
1530
|
+
const ret = fl.result ? `: ${fl.result}` : "";
|
|
1531
|
+
const note = Object.hasOwn(FILTER_NOTES, fl.name) ? FILTER_NOTES[fl.name] : void 0;
|
|
1532
|
+
filters.push(`- \`${fl.fl}(${sig})${ret}\`${note ? ` \u2014 ${note}` : ""}`);
|
|
1533
|
+
}
|
|
1534
|
+
const zeroArg = m.filters.filter((fl) => !fl.args?.length && !fl.variadic).map((fl) => fl.name);
|
|
1535
|
+
const variadic = m.filters.filter((fl) => fl.variadic).map((fl) => fl.name);
|
|
1536
|
+
filters.push(
|
|
1537
|
+
"",
|
|
1538
|
+
`Zero-argument filters \u2014 call as \`fl.<name>()\`; an argument is a compile error: ${zeroArg.join(", ")}.`,
|
|
1539
|
+
"",
|
|
1540
|
+
`Variadic filters \u2014 they take arguments, but no declared list, so the count is not checked: ${variadic.join(", ")}.`,
|
|
1541
|
+
""
|
|
1542
|
+
);
|
|
1543
|
+
const lambda = renderLambdaSection();
|
|
1544
|
+
lines.push("## Statements", "");
|
|
1545
|
+
lines.push(
|
|
1546
|
+
"Reachable through the `s` namespace: `s.<path>({...})`. Declarative statements",
|
|
1547
|
+
"take one typed args object (field names match the engine); every surface's field",
|
|
1548
|
+
"signature is in `llms/statements-catalog.md`. Specials (`[special]`) are",
|
|
1549
|
+
"hand-authored: control flow is below, and the rest are in the `llms/statements-*`",
|
|
1550
|
+
"topic files this doc's navigation names.",
|
|
1551
|
+
"Wrap an `input`-routed `value` field in `ignored(...)` to store it but SKIP it at",
|
|
1552
|
+
" runtime \u2014 the engine records `<name>:ignore` and the parameter falls back to its",
|
|
1553
|
+
" default. Not the same as an empty value, and not the same as omitting the field",
|
|
1554
|
+
" (which stores no entry at all). Mostly seen on a pulled workspace. A field written",
|
|
1555
|
+
" into `context` instead (the `s.math.*`/`s.text.append` mutation family) has no slot",
|
|
1556
|
+
" for the flag and THROWS rather than dropping it.",
|
|
1557
|
+
"Fields marked `value` take a `Value` (`c.*`/`ref`/`inp`); `comparison` takes an",
|
|
1558
|
+
"`expr(...)`. A `\u2192 as: <type>` suffix names what the statement's `as:` output var",
|
|
1559
|
+
"holds (curated, not exhaustive \u2014 absence means read the `[output]` flag and prose).",
|
|
1560
|
+
""
|
|
1561
|
+
);
|
|
1562
|
+
lines.push(
|
|
1563
|
+
"### Specials \u2014 authored signatures",
|
|
1564
|
+
"",
|
|
1565
|
+
"Control flow & blocks (each nests a sub-stack; block specials name it `body`):",
|
|
1566
|
+
"",
|
|
1567
|
+
"- `s.set_var(name, value)` \xB7 `s.update_var(name, value)` \xB7 `s.return(value)` \xB7 `s.comment(text)` \u2014 positional.",
|
|
1568
|
+
'- **Every** statement takes `disabled?`/`description?` \u2014 annotations on the stack item, not args: `disabled: true` is Xano\'s "disable step" (kept in the stack, skipped at runtime), `description` the note beside it. Inline on object-arg factories; a trailing object on the positional ones (`s.set_var("x", v, { disabled: true })`).',
|
|
1569
|
+
'- **Statements with an `as`** also take `asFilters?` \u2014 `fl.*` filters on the RESULT as it binds, in order, same slot as `disabled`: `s.set_var("x", v, { asFilters: [fl.trim(), fl.lower()] })`. Saves a follow-up `set_var`. Throws without an `as`. The bound variable is RETYPED by the chain (`db.query` + `[fl.count()]` \u2192 `number`); filters whose result the engine declares as `any` (`get`, `set`, `json_decode`, \u2026) fold to `unknown`.',
|
|
1570
|
+
"- `s.conditional({ when, then, elif?, else? })` \u2014 if/elif/else. `when` is a condition (`expr`/`cmp`/`and`/`or`); `elif` is an ordered `[{ when, then }]` (each an else-if branch); `then`/`else` are `Statement[]`.",
|
|
1571
|
+
"- `s.for({ as, count, body })` \u2014 **count-bounded** loop (`as` is the index), NOT from/to.",
|
|
1572
|
+
"- `s.foreach({ as, list, body })` \u2014 iterate `list`; `as` is the current item.",
|
|
1573
|
+
"- `s.while({ when, body })` \u2014 `when` is a condition (`expr`/`cmp`/`and`/`or`).",
|
|
1574
|
+
"- `s.switch({ on, cases: [{ when, body, break? }], default? })` \u2014 multi-way branch on a subject `Value` `on`; each `case`'s `when` is a literal `Value` matched against `on` (NOT a comparison \u2014 use `s.conditional` for `<`/`>`/ranges). \u26A0 **Omitting `break: true` FALLS THROUGH** \u2014 the matched case also runs every LATER case body. Type-checks clean; only `export --strict` catches it.",
|
|
1575
|
+
"- `s.try_catch({ try, catch?, finally? })` \u2014 three `Statement[]` blocks.",
|
|
1576
|
+
"- `s.group(body)` / `s.util.post_process(body)` \u2014 take a `Statement[]` **positionally**.",
|
|
1577
|
+
"- `s.foreach_break()` / `s.foreach_continue()` / `s.foreach_remove()` \u2014 nullary loop control.",
|
|
1578
|
+
"- `s.expect.to_throw({ body, exception? })` \u2014 `body` is the statements expected to raise.",
|
|
1579
|
+
""
|
|
1580
|
+
);
|
|
1581
|
+
const stmtData = [
|
|
1582
|
+
"Array blocks (an `if`/`transform` is applied per item):",
|
|
1583
|
+
"",
|
|
1584
|
+
'- `s.array.map({ source, as?, transform? })` \u2014 `transform` is either a per-item `Value` expression (each item maps to that value) or a **record of values** (each item maps to an object with those keys), or a list of `{ key, value }` pairs, for a key computed per item or two rows sharing one. Use `ref("$this")` for the item and `ref("$index")` for its position. These are THIS statement\'s own bindings, in a value expression \u2014 not the JavaScript lambda contract (see **Lambda bodies**), which binds a different set per surface and is written with `lam.fn`.',
|
|
1585
|
+
"- `s.array.union({ source, with?, as?, transform? })` \u2014 set-union two arrays.",
|
|
1586
|
+
"",
|
|
1587
|
+
"DB reads/writes (`table` is a def handle or name; `fieldName` defaults to the",
|
|
1588
|
+
"primary key `id`):",
|
|
1589
|
+
"",
|
|
1590
|
+
"- `s.db.get({ table, fieldName?, fieldValue, lock?, output?, as? })` \u2014 one row by field match; `output` restricts returned columns (and overrides column visibility \u2014 it can pull `internal` columns like a password hash).",
|
|
1591
|
+
"- `s.db.get_by_id({ table, id, output?, addon?, tableAlias?, as? })` \u2014 get by primary key. Takes `id`, NOT `fieldName`/`fieldValue`; binds the row or `null` like `db.get`. Both spellings are live in pulled workspaces.",
|
|
1592
|
+
"- `s.db.has({ table, fieldName?, fieldValue, as? })` \u2014 existence test.",
|
|
1593
|
+
"- `s.db.del({ table, fieldName?, fieldValue, as? })` \u2014 delete by field match.",
|
|
1594
|
+
"- `s.db.add({ table, row?, data?, output?, as? })` \u2014 insert; `row` is a partial keyed by column.",
|
|
1595
|
+
' - A row CELL takes a tagged `Value`, a nested object of sub-keys, or a bare JS literal typed against that column: `row: { is_hidden: true, notes: "\u2026" }` encodes exactly as `{ is_hidden: c.bool(true), notes: c.text("\u2026") }`. The tag comes from the COLUMN, not the literal \u2014 `10` on an `f.decimal()` column is `const:decimal`, not `const:int` \u2014 so a literal contradicting its column is a compile error on a `f.*`-schema table (`{ is_hidden: "yes" }` on an `f.bool()` column) and throws at encode on a raw-`ColumnDef[]` one. An `f.enum()` column keeps its member union. A column with no literal form \u2014 obj/json/list/geo/vector/file \u2014 still needs `c.obj`/`c.array`.',
|
|
1596
|
+
" - `null` is accepted on EVERY column, including ones that refuse every other literal, and encodes `const:null` \u2014 a write OF null, not the same as omitting the key (omitted takes the column's type default on `add`: `[]` for a list, `{}` for obj/json, else `null`; on `edit` it is left untouched). A column's `nullable` is not consulted; the engine refuses a null it forbids.",
|
|
1597
|
+
" - An `f.password()` cell takes the PLAINTEXT \u2014 the column hashes on write, so a pre-hashed value, or a hashing filter on the cell, stores a hash of a hash that `security.check_password` can never match.",
|
|
1598
|
+
"- `s.db.edit({ table, fieldName?, fieldValue, row?, data?, output?, as? })` \u2014 update by field match.",
|
|
1599
|
+
"- `s.db.patch({ table, fieldName?, fieldValue, data, output?, as? })` \u2014 merge a partial (`data` is an object value).",
|
|
1600
|
+
" On these three, `output` restricts the columns of the RETURNED row only \u2014 it does not change",
|
|
1601
|
+
" what is written. Not offered on `db.del`/`db.has` (their result is a scalar) or on",
|
|
1602
|
+
" `db.add_or_edit` (no output envelope).",
|
|
1603
|
+
"- `s.db.add_or_edit({ table, fieldName?, fieldValue, row?, data?, as? })` \u2014 upsert.",
|
|
1604
|
+
"- `s.db.query({ table, where?, additionalWhere?, bind?, sort?, paging?, external?, returnType?, distinct?, eval?, output?, lock?, addon?, as? })` \u2014 search.",
|
|
1605
|
+
" - `where` / `additionalWhere` \u2014 `expr(...)`, an `expr[]` (ANDed), or a raw `Value`. Rides `context.search`.",
|
|
1606
|
+
" - \u26A0 `ignoreEmpty` DROPS the predicate when the operand is empty \u2014 it does not match zero rows. On an `in` comparison an empty list therefore returns the UNFILTERED set, so never use it to scope rows to a permitted-id list: an empty list of permissions returns everything.",
|
|
1607
|
+
" - For the full operator set use `cmp(left, op, right, { ignoreEmpty? })` \u2014 `op`: `in`/`not in`/`like`/`ilike`/`between`/`contains`/`includes`/`overlaps`/`@>`/`~`/`search`/\u2026 plus the `expr` comparisons. Database-only \u2014 a runtime condition takes the `expr` set only.",
|
|
1608
|
+
" - Compose nested boolean logic with `and(...)` / `or(...)` groups (also available on `addon()` `where`).",
|
|
1609
|
+
" - An operand may be a bare value (`col`/`inp`/`ref`/`auth`/`c.*`) OR a **filtered** value (`withFilters(...)`) inline \u2014 the engine compiles the string and arithmetic filters (`trim`, `concat`, `upper`, `lower`, \u2026) into the SQL.",
|
|
1610
|
+
" - \u26A0 The REQUEST-TIME timestamp filters have no SQL form and kill the request with a bare fatal naming nothing: `epochms_transform`, `epochms_add_ms`, `epochms_add_secs`, `epochms_date`, `epochms_from_format`. For a relative cutoff use the SQL-side family instead (`epochms_add_day`, `epochms_sub_month`, `epochms_year`, \u2026) or compute it in an earlier `s.set_var` and `ref()` that. `export --strict` reports it; a bare `c.now()` operand is always fine.",
|
|
1611
|
+
' - `bind: [{ table, as?, join?, where? }]` \u2014 joins (`context.bind[]`). `join` defaults to `"inner"`. `as` defaults to the table name; two joins to the same table need distinct aliases.',
|
|
1612
|
+
' - \u26A0 In `where`/`sort`/`eval` a JOINED column takes a dotted path (`col("team_row.id")`); THIS query\'s own columns stay **bare** (`col("team")`). Qualifying your own by table name needs `tableAlias` (same rule as `aggregate`) \u2014 without it the engine reads the operand as text and 400s `ParseError: Invalid value for param` naming the OTHER operand, so it throws at export instead.',
|
|
1613
|
+
' - `bind: [{ table: team, as: "team_row", join: "left", where: expr(col("team"), "=", col("team_row.id")) }]`',
|
|
1614
|
+
' - `returnType` \u2014 `"list"` (default) | `"single"` | `"count"` | `"exists"` | `"stream"` | `"aggregate"`. Drives `context.return.type` AND the `InferResponse` shape: `count`\u2192`number`, `exists`\u2192`boolean`, `single`\u2192`Row|null`, `stream`\u2192`Row[]` (pageable, no envelope), `list`\u2192`Row[]`/envelope, `aggregate`\u2192rows keyed by the `aggregate.group`/`eval` aliases. \u26A0 A bare `count` of ZERO serializes as an EMPTY body, not `0` \u2014 a client parsing JSON gets a parse error on the one result it most needs to handle. Wrap it: `response: { count: ref("n") }`.',
|
|
1615
|
+
' - `eval: [{ name, as, filters? }]` \u2014 computed columns (`context.eval[]`). Each `as` grafts onto the row as an `unknown` key in `InferResponse`; shadowing a real column throws. Write `name` **bare** (`"embedding"`) \u2014 it is alias-qualified on emit exactly like `aggregate` (a bare eval name is `Unsupported param format` at runtime), and the statement declares the alias it used. An `as` alias is `sort`able in the SAME query.',
|
|
1616
|
+
" - An `eval`/`sort`/`where` filter pipeline compiles to **SQL**, so it resolves a DIFFERENT registry than `fl.*` (which runs in the request): the vector family, geo `distance`/`within`/`covers`, `search_rank`, the aggregators. Exported as `QUERY_EXPRESSION_FILTERS`/`VECTOR_FILTERS`.",
|
|
1617
|
+
' - **Vector similarity search** \u2014 the ONLY way to query an `f.vector` column (no `SearchOp` does distance). `eval: [{ name: "embedding", as: "distance", filters: [{ name: "vector_cos_distance", arg: [inp("q")] }] }]` + `sort: [{ sortBy: "distance", dir: "asc" }]` ranks in the DATABASE over the column\'s index. Match the filter to the index `op` (`vector_cos_distance`\u2194`vector_cosine_ops`, `vector_l2_distance`\u2194`vector_l2_ops`, `vector_l1_distance`\u2194`vector_l1_ops`, `vector_inner_product`\u2194`vector_ip_ops`); `vector_cos_similarity` is the inverse, so sort it `desc`. The same filter on a `where` operand cuts off BY distance instead of by row count.',
|
|
1618
|
+
' - `aggregate: { group?, eval?, sort?, paging? }` (with `returnType:"aggregate"`) builds `context.return.aggregate`. `group`/`eval` are `{ name, as, filters? }`, an aggregator like `sum`/`count` riding `filters`. Some aggregators resolve ONLY here, not in a runtime value pipeline: `count_distinct`, `median`, `to_list`/`to_distinct_list` (each with `_asc`/`_desc`), and `vector_distance`.',
|
|
1619
|
+
' - \u26A0 Write each `name` as a **bare** column (`"status"`). It is alias-qualified to `"<alias>.status"` on emit \u2014 the engine rejects an unqualified column in an aggregate with `Unsupported param format`. An already-dotted `name` (a `bind`ed/joined column) passes through.',
|
|
1620
|
+
" - The alias it qualifies WITH is `tableAlias` when you set one, otherwise the table's name \u2014 and the statement DECLARES that alias (`dbo.as`) so the qualified name resolves. Nothing to do by hand; a bare `name` is the form to write.",
|
|
1621
|
+
' - `sort: [{ sortBy: <col>, dir?: "asc"|"desc"|"rand" }]` and `paging: { page?, per_page?, offset?, totals?, metadata?, search?, sort? }` ride `context.return.list`.',
|
|
1622
|
+
" - \u26A0 `paging` with a page/per_page/offset field and `metadata` on (the DEFAULT) wraps the result in an envelope `{ items: Row[], curPage, nextPage, prevPage, offset, perPage, itemsReceived }` \u2014 plus `itemsTotal`/`pageTotal` when `totals: true` \u2014 instead of a bare `Row[]`. `InferResponse` reflects it. Pass `metadata: false` to keep the bare array.",
|
|
1623
|
+
" - Read `nextPage` (`number|null`) as the typed has-next signal.",
|
|
1624
|
+
' - **Input-bound paging:** `page`/`per_page`/`offset` also accept a `Value` (`inp("page")`), riding `context.simpleExternal` while the static block stays the engine gate (`enabled:true`). `paging.search`/`sort` are `Value` dynamic overrides.',
|
|
1625
|
+
" - A `search`/`sort`-only `paging` (no numeric field) does NOT paginate.",
|
|
1626
|
+
" - `external: { value, permissions? }` \u2014 the classic whole-config blob (forces the gate on). It falls back to input-bound `paging` when it resolves empty, so supplying both is valid.",
|
|
1627
|
+
' - `distinct` \u2014 `"auto"` (default) | `"yes"` | `"no"`, riding `context.return.<list|stream>.distinct`.',
|
|
1628
|
+
"- `s.db.truncate({ table, reset?, as? })` \xB7 `s.db.schema({ table, path, as? })`.",
|
|
1629
|
+
'- `s.db.direct_query({ sql, responseType?, args?, parser?, as? })` \u2014 `sql` is a **raw string** (not a `Value`); binds go in `args: Value[]`. `parser: "template_engine"` renders the body as a template first \u2014 how a query interpolates a column or table name a bound arg cannot carry; omit it for the default.',
|
|
1630
|
+
"- `s.db.external.<engine>.direct_query({ sql, connectionString, responseType?, args?, parser?, as? })` \u2014 same shape against an EXTERNAL database; `<engine>` is `postgres`/`mysql`/`mssql`/`oracle`/`snowflake`. `connectionString` is a `Value` \u2014 reach for `env(...)`, not a literal \u2014 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.",
|
|
1631
|
+
"- `s.db.transaction({ body, as? })` \u2014 run a `Statement[]` atomically. `as` binds whatever the block returned.",
|
|
1632
|
+
"- `s.db.bulk.add({ table, items, allowIdField?, as? })` / `s.db.bulk.update` / `s.db.bulk.patch` \u2014 `items` is an array `Value`.",
|
|
1633
|
+
" - \u26A0 `bulk.add` **drops `id` on every row unless `allowIdField: true`** (silently, next sequence value instead) \u2014 the opposite of `seed`, where `id` pins. Rows referenced by a foreign key need `allowIdField: true`; literal `items` carrying `id` without it throw. `bulk.update`/`patch` keep `id` (their match key).",
|
|
1634
|
+
' - \u26A0 **`bulk.update` is a whole-row REPLACE: every column an item OMITS is zeroed** (`""`/`0`/`null`), HTTP 200, no error \u2014 `{ id: 7, status: "done" }` blanks the rest of row 7. **Use `s.db.bulk.patch`** for the partial write "update these rows" means. `export()` warns on a STATIC `items` missing columns (`--strict` fails); a `ref`/`inp` `items` is uninspectable.',
|
|
1635
|
+
"- `s.db.bulk.delete({ table, where?, allRows?, as? })` \u2014 deletes rows by a `context.search` filter. `where` is the same surface as `s.db.query` (`expr(...)`/`cmp(...)`, `and(...)`/`or(...)` groups, an array of those ANDed, or a raw `Value`) and encodes through the identical `{expression:[\u2026]}` search shape. \u26A0 A filter that constrains nothing deletes **every** row, so a missing or empty `where` **throws**: pass the filter, or `allRows: true` for a deliberate wipe (both together also throw). `allRows` emits the empty search the engine requires and returns the deleted count; reach for `s.db.truncate({ table, reset: true })` when the id sequence should restart too.",
|
|
1636
|
+
""
|
|
1637
|
+
];
|
|
1638
|
+
const stmtRuntime = [
|
|
1639
|
+
"Runtime behavior (what the `as:` output holds, and misses):",
|
|
1640
|
+
"",
|
|
1641
|
+
"- `db.get` binds **`null`** when no row matches (it does NOT throw) \u2014 so the output is `InferRow<typeof table> | null`; null-check it. On a hit it binds the **full row**. (`db.has` is the boolean existence test.)",
|
|
1642
|
+
"- `db.edit` binds the **full, post-mutation row** (the freshly-written values, not the pre-edit ones). `db.add` binds the **full inserted row**, including the auto-assigned `id` and `created_at`. So `InferRow<typeof table>` is the right response type for those two. `db.del` **binds `null`** \u2014 the engine deletes the row and returns no value, so don't return the `as` var expecting the deleted row.",
|
|
1643
|
+
"- Unlike `db.get`, `db.edit` and `db.del` **throw** `NotFound` (HTTP 404) when no row matches the field. `db.add` throws on a unique-constraint violation.",
|
|
1644
|
+
"- **`InferResponse<typeof query>`** derives an endpoint's response type (read-side round trip, no codegen). It resolves object-literal responses to those keys; a `response: ref(\"x\")` returning a variable bound by a TOP-LEVEL db op on a `table()` resolves to that op's result:",
|
|
1645
|
+
" | statement | resolves to | on a miss |",
|
|
1646
|
+
" |---|---|---|",
|
|
1647
|
+
" | `db.add` / `db.edit` / `db.patch` / `db.add_or_edit` | `Row` (the full written row, non-nullable) | throws \u2014 `NotFound`/404 for `edit`/`patch`, a unique-constraint error for `add`; `add_or_edit` upserts and never misses |",
|
|
1648
|
+
" | `db.get` | `Row \\| null` | binds `null` rather than throwing |",
|
|
1649
|
+
" | `db.query` / `db.bulk.patch` | `Row[]` | \u2014 |",
|
|
1650
|
+
" | `db.has` | `boolean` | \u2014 |",
|
|
1651
|
+
" | `db.bulk.delete` | `number` (count) | \u2014 |",
|
|
1652
|
+
" | `db.del`, `db.bulk.add`/`bulk.update`, raw `direct_query` | `unknown` (the engine leaves them untyped) | \u2014 |",
|
|
1653
|
+
' - A `get`/`query` `output: [...]` selection narrows to a `Pick` (still `| null` for `get`). A dotted `ref("row.col")` into a `db.get` row projects that column carrying the `| null` (\u2192 `Col | null`).',
|
|
1654
|
+
" - A value reshaped by a filter/lambda, or a variable built by control flow / `set_var`, also resolves to `unknown`.",
|
|
1655
|
+
' - A CALL binds the TARGET\'s response type: `s.function.call`/`s.function.run`/`s.api.call`/`s.tool.call` given a def HANDLE brand their `as` with `InferResponse` of the target, so `ref("out.field")` types to that field. What the target resolves to is what propagates \u2014 declare `responseShape` on the TARGET to fix every caller at once. A target named by STRING has no def to read and stays `unknown`, as does an async `s.function.run` (it binds a job handle, not the result).',
|
|
1656
|
+
' - \u26A0 In an object-literal response the KEYS are always known, but a VALUE is typed only when it traces to a binding \u2014 a `ref`, or a `ref` under `withFilters`. A CONSTANT does not trace: `response: { success: c.bool(true), hello: c.text("hi") }` derives `{ success: unknown, hello: unknown }`, NOT `{ success: boolean, hello: string }`. Same for an `inp` outside a trace. Only a reference carries a type the derivation can follow. Declare `responseShape` when a client needs those keys typed.',
|
|
1657
|
+
' - A NESTED member resolves to any depth by these same rules, in either spelling \u2014 `{ user: obj({ id: ref("row.id") }) }` and the raw literal `{ user: { id: ref("row.id") } }` both derive `{ user: { id: Col | null } }`. A raw scalar member types itself (`{ count: 3 }` \u2192 `number`).',
|
|
1658
|
+
" - Runs on every response-bearing kind \u2014 `query`, `defineFunction`, `realtimeMessage`, `tool`, `middleware`, and the response-bearing triggers \u2014 each of which also accepts `responseShape`. A trigger builds its stack and response through CALLBACKS (`stack: (t) => [...]`), and the trace follows through them.",
|
|
1659
|
+
" - Close any `unknown` by declaring `responseShape` on the def (`responseShape: null as InferRow<typeof t> | null`) \u2014 the declaration ALWAYS overrides derivation. It is available on EVERY kind listed above, `query` included.",
|
|
1660
|
+
" - The shapes that never derive, and the one line that closes each: rows built in `s.lambda` (JS is opaque to the walk) \u2192 `responseShape: [] as Row[]`; a var assigned across `s.conditional`/`s.switch` branches \u2192 `responseShape` for the union you meant; a call whose target is named by STRING, or an async `s.function.run` \u2192 give the TARGET a `responseShape` (a handle then carries it) or declare it here. Reach for `responseShape` as the FIRST move on these, not after fighting the derivation.",
|
|
1661
|
+
" - \u26A0 A RUNTIME guard does not narrow the STATIC type. A `s.precondition` (or a conditional) proving a `db.get` row non-null leaves `InferResponse` at `Row | null`, because the walk reads the stack's shape and not its control flow \u2014 the `| null` is correct about the derivation and stale about your stack. Declare `responseShape` to drop it rather than asserting at every call site.",
|
|
1662
|
+
' - \u26A0 A `resultStrategy: "replace"` middleware attached `post` reshapes the endpoint\'s output at runtime, which the static walk cannot see. Declare `responseShape` when a post middleware rewrites the response.',
|
|
1663
|
+
" - \u26A0 **Spreading a `Statement[]` helper into a stack kills the whole walk.** The trace needs the stack's TUPLE type, so `...myHelper()` where the helper returns `Statement[]` widens it and EVERY `as` in that stack \u2014 including ones declared after the spread \u2014 stops resolving. The response then types as `StackTupleWidened`, whose name says so. Fix: return `statements(s.a(...), s.b(...))` from the helper (a const-generic identity export \u2014 the tuple survives the spread). A helper that builds its array in a LOOP cannot be a tuple; declare `responseShape` there.",
|
|
1664
|
+
"- **Addons** enrich returned rows. `db.query`/`get`/`add`/`edit`/`patch` accept `addon: [{ addon, as, input?, output?, children? }]`; `db.add_or_edit`/`del`/`has`/`truncate` take no `addon`.",
|
|
1665
|
+
' - `addon` is the target (name or def handle). `as` is the destination on the row \u2014 a bare alias (`"_user"`) or a dotted `offset.alias`, authored relative to a row. Under a metadata paging envelope the `items[]` offset is prefixed automatically; writing it yourself is tolerated and not double-prefixed.',
|
|
1666
|
+
" - `input` maps addon inputs \u2014 bind a parent-row column with `out(col)`. `output` restricts addon columns. `children` nests addons.",
|
|
1667
|
+
' - An addon is a single table-bound db query, NOT a statement stack: `addon({ name, table, tableAlias?, where?, sort?, output: [cols], cardinality?: "single"|"list"|"count"|"exists"|"aggregate", group?, eval?, input?, context? })`, registered via `registerAddons([...])`.',
|
|
1668
|
+
" - `table` auto-fills the `context.dbo` binding. \u26A0 Never author `table: null` \u2014 that is a BROKEN table-less addon returning nothing; `codegen` emits it only for an already-broken pulled object.",
|
|
1669
|
+
' - `tableAlias` is its SQL alias (`context.dbo.as`), qualifying `where`/`sort` columns (`col("merchant.id")`).',
|
|
1670
|
+
' - `where`/`sort` take the same surface as `s.db.query` and encode `context.search`/`context.sort`. `where` is the predicate binding the addon to the parent row \u2014 `expr(col("id"), "=", inp("user_id"))`.',
|
|
1671
|
+
' - `cardinality` shapes the result (`context.return.type`, omitted for the `"list"` default). Rarer context (`eval`/`bind`/`lock`) stays raw `context` passthrough.',
|
|
1672
|
+
" - Attaching a typed `addon({ table, output })` handle merges its alias (the last `as` segment) onto the row in `InferResponse`: `{cols}` for `single`, `{cols}[]` for `list`, `number` for `count`, `boolean` for `exists`, and for `aggregate` an array keyed by the `group`/`eval` aliases (`unknown` values; `unknown` when neither is declared).",
|
|
1673
|
+
" - An attachment-level `output` narrows an object/array graft further. A bare-NAME reference grafts `unknown` \u2014 narrow it at the call site.",
|
|
1674
|
+
" - \u26A0 An alias that shadows an existing column on the queried table throws at build time; rename with a `_` prefix.",
|
|
1675
|
+
"- **Middleware attachment** runs a reusable `middleware({...})` before/after a host's own stack. Attach with the host's `middleware: { pre, post }` field on `query`/`function`/`task`/`tool`/`apiGroup` (NOT triggers): each phase is an ordered list of middleware refs (def handle or name), or `{ middleware, active: false }` to keep an entry disabled. Providing a phase **overrides** it (sets the stored `pre_customize`/`post_customize` flag); omitting a phase **inherits** the parent tier's chain \u2014 the engine resolves Query \u2192 API Group \u2192 Workspace at request time (override, not merge; the API-Group tier applies to queries \u2014 functions/tasks/tools have no API-group binding and inherit straight from the workspace). Prefer a def handle over a bare name when the middleware pins an explicit `guid`. `pre: middleware.clear()` (an empty list) overrides with nothing \u2014 stop inheriting. Workspace-level defaults are the terminal tier: `workspaceConfig({ middleware: { query: { pre }, function, task, tool } })` emits the flat `{host}_{phase}` map (no `_customize` flags) \u2014 setting it replaces the whole workspace map, so unlisted hosts are cleared; omit the field to leave existing workspace middleware untouched. Distinct from `s.middleware.call` (inline invoke).",
|
|
1676
|
+
'- **Middleware request context.** A `pre` middleware runs **after** auth resolution, so `auth()` is available inside the middleware when the host is authenticated (its `auth` names an auth table); on a public host `auth()` is `null`. This matters for the canonical use \u2014 a rate limit keyed by `auth("id")`: on an authenticated endpoint the bucket is per-user, but attach the same middleware to a public endpoint and every anonymous caller keys under the same `null` id (one shared bucket), silently. To catch that, `export()` **warns** (never blocks) when a middleware whose stack references `auth()` is directly attached to a host where `auth()` may be null \u2014 a `query` with no auth table, a `task` (scheduled, never authenticated), or a `function`/`tool` (whose auth is caller-dependent). An authenticated query (its own `auth` table set) is skipped. The check is direct-attachment only; a middleware reaching a public query via API-group/workspace tier inheritance is not caught.',
|
|
1677
|
+
'- **Rate-limit recipe (the canonical middleware).** Per-user rate limiting is the most common middleware. Author it with `s.redis.ratelimit` and a **composite key** built via the filter chain \u2014 `"prefix" + auth("id")` does not exist, you build the key: `middleware({ name: "write_rl", exceptionPolicy: "rethrow", stack: [ s.redis.ratelimit({ key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), max: c.int(10), ttl: c.int(30), error: c.text("Too fast.") }) ] })`. `exceptionPolicy` defaults to `"rethrow"`, which is what makes a tripped limit abort with HTTP 429; `"silent"` would let the over-limit request through. Attach it with `middleware: { pre: [writeRl] }` on an **authenticated** host (its `auth` set) so `auth("id")` keys per-user; on a public host `auth("id")` is null and every caller shares one bucket (`export()` warns \u2014 see request context above). **Shared-bucket rule:** co-attaching one middleware object to N hosts means all N share the *same* key \u21D2 *one* counter \u2014 `max: 10` is a global per-user budget across them, not 10-per-host. Vary the key (fold in the host/action name) for an independent limit per host.',
|
|
1678
|
+
'- **Middleware `exceptionPolicy`** governs what a **throw** in the middleware stack does to the request (XanoTS passes the value through; the Xano engine interprets it). `"rethrow"` is the **default** \u2014 the throw aborts the request and surfaces the authored `error`/status (a tripped `s.redis.ratelimit` \u2192 HTTP 429); the `post` chain still runs. `"silent"` swallows the throw, so a guard set to it is **not enforced** \u2014 advisory middleware only. `"critical"` is `"rethrow"` plus skipping the `post` chain. The only difference between `rethrow` and `critical` is whether `post` runs \u2014 no status or logging change.',
|
|
1679
|
+
"",
|
|
1680
|
+
'- **Request history** controls per-object execution capture (the request/task/trigger debugger). Authored as a single scalar `history` field on any primitive: `false` off, `true` on at the default capture depth, a number = capture depth (how many statement executions are recorded per history record \u2014 NOT record retention), `"all"` unlimited. **Omit `history` to inherit** \u2014 the engine resolves object \u2192 container \u2192 workspace at request time (a query inherits from its API group, a tool from its toolset envelope, everything else straight from the workspace). Any authored value stops inheriting for that object. Per-kind defaults (when inheriting): query/task/tool capture ON, function/trigger/middleware OFF; default depth 100. Container tiers are authorable too \u2014 `apiGroup({ history })` sets the `query_*` default its queries inherit, and an agent/mcp_server/toolset `history` sets the `tool_*` default its tools inherit. Workspace-level defaults are the terminal tier: `workspaceConfig({ history: { query, function, task, tool, trigger, middleware } })` emits the flat `{objType}_enabled`/`{objType}_limit` map (no inherit flag) \u2014 setting it is wholesale (unlisted types fall back to their engine default), so declare every default you want to keep; omit the field to leave existing workspace history untouched.',
|
|
1681
|
+
"",
|
|
1682
|
+
'- **Workspace environment variables** set a tenant\'s env vars through the workspace object: `workspaceConfig({ env: { STRIPE_KEY: process.env.STRIPE_KEY!, APP_BASE_URL: "https://\u2026" } })`. Author them as a name\u2192value MAP. Read a var back with `env("NAME")` (\u2192 `$env.NAME`), which compiles to tag "setting" with the plain name. Values are SECRETS: prefer sourcing from `process.env` over committing literals, and don\'t commit a compiled bundle with real values. `deploy` REPLACES the tenant\'s env with the declared map; `release` (merge) is ADD-ONLY \u2014 it creates missing keys but does NOT update or remove existing ones, so changing a value in code and releasing leaves the live value unchanged. Omit `env` to leave existing env untouched. The separate `settings` field is a plain object.',
|
|
1683
|
+
""
|
|
1684
|
+
];
|
|
1685
|
+
const stmtCalls = [
|
|
1686
|
+
"Auth & calls:",
|
|
1687
|
+
"",
|
|
1688
|
+
"- `s.security.create_auth_token({ table, id, extras?, expiration?, as? })` \u2014 `extras` defaults to `{}`, `expiration` to `86400`s (`0` = never).",
|
|
1689
|
+
"- `s.security.create_guid({ as? })` \u2014 bind a fresh GUID string. Takes nothing else.",
|
|
1690
|
+
"- `s.function.run({ fn, input?, as?, runtime? })` / `s.function.call({ fn, input?, as? })` \u2014 run another function; `input` is keyed by the target's input names.",
|
|
1691
|
+
' - `runtime?` runs it in the BACKGROUND: `{ mode: "async-shared" }` or `{ mode: "async-dedicated", cpu?, memory?, timeout?, maxRetry? }` (resources read at dedicated only). An async call DOES NOT return the result \u2014 it dispatches and continues, so `as` binds nothing; collect with `s.await({ ids })`. Omit for a normal call. Same block on `s.ai.agent.run`.',
|
|
1692
|
+
'- `s.api.call({ api, input?, headers?, auth?, as? })` \u2014 invoke an endpoint. `headers` REPLACES the request headers the callee sees and takes the same shapes as `s.api.request`\'s \u2014 a `{ "Name": value }` record (values may be tagged), a `string[]` of `"Name: value"` lines, or one `Value`. `auth` is `{ token, ignoreExpiration? }`: `token` must be a BARE STRING \u2014 a tagged `Value` deploys clean and then fails the run with `Param: token - Text filter requires an integer, float, string or boolean value`, since the engine stores that slot as plain text and never evaluates it. Neither slot authenticates the call today; see `llms/tests.md` for what a workflow-test run actually sees. WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`) \u2014 elsewhere it deploys clean and 500s the first real request.',
|
|
1693
|
+
'- `s.api.request({ url?, method?, params?, headers?, timeout?, follow_location?, verify_host?, verify_peer?, ca_certificate?, certificate?, certificate_pass?, private_key?, private_key_pass?, description?, output?, as? })` \u2014 external HTTP request (`mvp:api_request`). Ergonomic types, each also accepting a dynamic `Value`: `method` suggests the 7 verbs (GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH), `params` a plain JSON object **or** a FLAT record whose values are tagged `Value`s (`{ count: ref("count") }`, each lifted via a `set` filter \u2014 the same record-of-values shape `response: { key: value }` takes); a tagged value NESTED inside an object or array THROWS at encode, so wrap a structured body in `obj({...})`, which encodes any depth as one `const:expr2` (\u2192 query string for GET/HEAD/OPTIONS, body otherwise), `headers` a `{ "Name": value }` record whose values may be tagged (`{ "x-api-key": env("KEY") }`, each pair joined to a `"Name: value"` line) **or** a `string[]` of full header lines \u2014 prefer a header over a `?key=` query param for a credential \u2014 a URL travels into access logs, proxies and `Referer`. \u26A0 Neither spelling is envelope-safe: the `as` envelope\'s `request` half mirrors `url`, `params` AND `headers`, so never return it raw from a credentialed request \u2014 read `response.result`. A NAME outside the header-token charset is refused, and a LITERAL value carrying a newline is refused; a value may hold `:` and spaces (`Bearer a: b` is a valid value). A TAGGED value cannot be checked at build time, so strip CRLF from caller-controlled input before this slot. Literal pairs lead the emitted array and computed ones follow, so the wire order is not the record\'s key order, `timeout` a `number` in seconds (1\u201386400), and `follow_location`/`verify_host`/`verify_peer` booleans. `description` (Settings tab) and `output` filters (Output tab) ride the envelope. SSL cert interdependencies (certificate\u2194private_key, ca_certificate\u2192verify_peer) are checked at build time when statically provable, else by the engine at runtime. The `as` result is typed as the `{request, response}` envelope (`response.status: number`, `response.result: unknown`), so `InferResponse` resolves a `ref` to it. Same typed result on `webflow.request` and `microservice.request`.',
|
|
1694
|
+
"- `s.stream.from_request({ url?, method?, \u2026tls, as? })` \u2014 streaming external HTTP request (`mvp:streaming_api_request`); same typed field surface as `s.api.request` (no description/output envelope).",
|
|
1695
|
+
"- `s.webflow.request({ path?, method?, \u2026tls, as? })` \u2014 Webflow API request (`mvp:connect_webflow_api_request`); like `s.api.request` but addressed by `path` (host is engine-supplied).",
|
|
1696
|
+
"- `s.task.call` / `s.tool.call` / `s.trigger.call` / `s.middleware.call` / `s.addon.call` \u2014 same `{ <target>, input?, as? }` shape against the named kind. `s.task.call` and `s.trigger.call` are WORKFLOW-TEST ONLY (see Gotchas in `llms.txt`); `s.tool.call`, `s.middleware.call` and `s.addon.call` run from any stack.",
|
|
1697
|
+
"- `s.action.call({ action, package?, input?, as? })` / `s.action.package.call(\u2026)` \u2014 invoke a marketplace action; `package` is its slug.",
|
|
1698
|
+
"- `s.cloud.job({ image?, command?, args?, secret?, template?, await?, as? })` \u2014 launch a containerized job. \u26A0 `await` is SECONDS (default 60), not a boolean.",
|
|
1699
|
+
"- `s.cloud.job.status({ id, as? })` \xB7 `s.cloud.job.await({ ids, timeout, as? })` \u2014 poll one job, or block on several (`ids` a list `Value`, `timeout` in seconds; both required).",
|
|
1700
|
+
"",
|
|
1701
|
+
"Microservices (the `microservice()` def and the statement that calls it):",
|
|
1702
|
+
"",
|
|
1703
|
+
'- `s.microservice.request({ host, path, port?, method?, params?, headers?, timeout?, follow_location?, as? })` \u2014 in-cluster microservice call (`mvp:microservice_request`); no TLS fields. ONLY `host`+`path` required; the rest default to the engine\'s values (`GET`/`{}`/`[]`/`10`/`true`), always emitted. Pass the `microservice()` DEF as `host` \u2014 it binds by NAME (how the engine resolves it), so a rename fixes every call site and the port is checked before deploy. `port?` folds into `host` as `"name:port"`: a def exposing ONE `servicePort` resolves automatically, SEVERAL requires it. A raw `"name:port"` string works, unvalidated, and is the only way to reach an instance-level microservice. `tenantDeploy: "manual"` on the def imports the row without starting the workload.',
|
|
1704
|
+
"- `s.workflow_test.call({ workflowTest, datasource?, as? })` \u2014 run another workflow test from inside one, which is the only place it runs (see Gotchas in `llms.txt`). The odd one out: NO `input` (a workflow test takes none), and it carries `datasource?` instead \u2014 same clone caveat as the kind's own field.",
|
|
1705
|
+
""
|
|
1706
|
+
];
|
|
1707
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1708
|
+
for (const s of m.statements) {
|
|
1709
|
+
if (s.legacy) continue;
|
|
1710
|
+
const ns = s.sPath.includes(".") ? s.sPath.slice(0, s.sPath.indexOf(".")) : "(top-level)";
|
|
1711
|
+
(groups.get(ns) ?? groups.set(ns, []).get(ns)).push(s);
|
|
1712
|
+
}
|
|
1713
|
+
const stmtCatalog = [];
|
|
1714
|
+
for (const ns of [...groups.keys()].sort()) {
|
|
1715
|
+
stmtCatalog.push(`### ${ns}`, "");
|
|
1716
|
+
const note = NAMESPACE_NOTES[ns];
|
|
1717
|
+
if (note) stmtCatalog.push(note, "");
|
|
1718
|
+
for (const s of groups.get(ns)) {
|
|
1719
|
+
const call = `s.${s.sPath}`;
|
|
1720
|
+
const flags = [s.declarative ? null : "special", s.registered ? null : "unregistered", s.output ? "output" : null].filter(Boolean).join(", ");
|
|
1721
|
+
const flagSuffix = flags ? ` [${flags}]` : "";
|
|
1722
|
+
const resultSuffix = s.result ? ` \u2192 ${s.result.name}: ${s.result.type}${s.result.note ? ` (${s.result.note})` : ""}` : "";
|
|
1723
|
+
if (s.fields) {
|
|
1724
|
+
const args = s.fields.map((f) => fieldLine(f, s.sPath)).join("; ");
|
|
1725
|
+
stmtCatalog.push(`- \`${call}({ ${args} })\`${flagSuffix}${resultSuffix}`);
|
|
1726
|
+
} else {
|
|
1727
|
+
stmtCatalog.push(`- \`${call}\`${flagSuffix}${resultSuffix}`);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
stmtCatalog.push("");
|
|
1731
|
+
}
|
|
1732
|
+
const legacy = [];
|
|
1733
|
+
const legacyValues = m.values.constructors.filter((v) => v.legacy);
|
|
1734
|
+
const legacyStatements = m.statements.filter((s) => s.legacy);
|
|
1735
|
+
const legacyFactories = m.objectKinds.flatMap((k) => (k.subKinds ?? []).filter((sub) => sub.legacy));
|
|
1736
|
+
if (legacyValues.length + legacyStatements.length + legacyFactories.length + SUPERSEDED_STATEMENTS.size + DECODE_ONLY_STATEMENTS.size > 0) {
|
|
1737
|
+
legacy.push(
|
|
1738
|
+
// The heading the topic file drops in favor of its own title; kept so the
|
|
1739
|
+
// block reads as a section wherever it is rendered from.
|
|
1740
|
+
"## Legacy",
|
|
1741
|
+
"",
|
|
1742
|
+
"Older paradigms this SDK still supports and still emits when it decodes an existing",
|
|
1743
|
+
"workspace. **Do not author these.** They are listed by name only so you recognize them",
|
|
1744
|
+
'in pulled code rather than "fixing" them; each line names what to use instead.',
|
|
1745
|
+
"",
|
|
1746
|
+
"Names overlap across the split deliberately \u2014 the engine reused words like",
|
|
1747
|
+
'"realtime" and "channel" for both generations. A name matching is NOT evidence that',
|
|
1748
|
+
"two things are the same object; check which list it came from.",
|
|
1749
|
+
""
|
|
1750
|
+
);
|
|
1751
|
+
for (const v of legacyValues) legacy.push(`- \`${v.name}\` \u2014 ${v.description}`);
|
|
1752
|
+
for (const f of legacyFactories) legacy.push(`- \`${f.authorFactory}()\` \u2014 ${f.description}`);
|
|
1753
|
+
for (const s of legacyStatements) {
|
|
1754
|
+
legacy.push(`- \`s.${s.sPath}\` \u2014 ${LEGACY_SURFACES[s.surface]}`);
|
|
1755
|
+
}
|
|
1756
|
+
const retired = [...SUPERSEDED_STATEMENTS.entries()];
|
|
1757
|
+
if (retired.length > 0) {
|
|
1758
|
+
legacy.push("");
|
|
1759
|
+
legacy.push(
|
|
1760
|
+
"Retired statement VERSIONS \u2014 no `s.` surface exists. Pulled code shows them as",
|
|
1761
|
+
'`raw({ name: "\u2026" })` and they keep running as stored, so leave them; author the',
|
|
1762
|
+
"replacement only for NEW code. Never swap one for the other \u2014 each version broke the last.",
|
|
1763
|
+
""
|
|
1764
|
+
);
|
|
1765
|
+
for (const [stored, successor] of retired) {
|
|
1766
|
+
legacy.push(successor ? `- \`${stored}\` \u2192 \`${successor}\`` : `- \`${stored}\` \u2014 retired, no replacement`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const decodeOnly = [...DECODE_ONLY_STATEMENTS.entries()];
|
|
1770
|
+
if (decodeOnly.length > 0) {
|
|
1771
|
+
legacy.push("");
|
|
1772
|
+
legacy.push(
|
|
1773
|
+
"Statements the engine writes but will NOT import back \u2014 no `s.` surface exists, and",
|
|
1774
|
+
"unlike the retired versions above these must be FIXED, not left alone. Pulled code shows",
|
|
1775
|
+
'them as `raw({ name: "\u2026" })`; `export()` refuses any bundle that still contains one.',
|
|
1776
|
+
""
|
|
1777
|
+
);
|
|
1778
|
+
for (const [stored, reason] of decodeOnly) legacy.push(`- \`${stored}\` \u2014 ${reason}.`);
|
|
1779
|
+
}
|
|
1780
|
+
legacy.push("");
|
|
1781
|
+
}
|
|
1782
|
+
legacy.push(
|
|
1783
|
+
"## Reading a pulled tree",
|
|
1784
|
+
"",
|
|
1785
|
+
"`codegen` emits objects as FACTORY calls (`table({...})`, `query({...})`, \u2026) \u2014 the form",
|
|
1786
|
+
"you author by hand \u2014 so inference survives the round trip.",
|
|
1787
|
+
'Three shapes read differently and must not be "fixed": a trigger no factory expresses',
|
|
1788
|
+
"(the realtime types, or a non-default `history`) stays `{...} satisfies TriggerDef`; a",
|
|
1789
|
+
"statement the catalog cannot model round-trips verbatim through `raw()`",
|
|
1790
|
+
"(`@xanots/sdk/codegen`); and an object ALREADY EMPTY upstream decodes to a def with",
|
|
1791
|
+
"no `stack`, reported as `empty-source` \u2014 faithful, not a decode failure. \u26A0 Workspace env",
|
|
1792
|
+
"var VALUES ride inline in `xano/workspace.ts`, so treat a pulled tree as secret-bearing.",
|
|
1793
|
+
""
|
|
1794
|
+
);
|
|
1795
|
+
return {
|
|
1796
|
+
router: lines,
|
|
1797
|
+
legacy,
|
|
1798
|
+
objectKinds,
|
|
1799
|
+
kindsCore,
|
|
1800
|
+
kindsAgentMcp,
|
|
1801
|
+
kindsRealtime,
|
|
1802
|
+
triggers,
|
|
1803
|
+
stmtData,
|
|
1804
|
+
stmtRuntime,
|
|
1805
|
+
stmtCalls,
|
|
1806
|
+
stmtCatalog,
|
|
1807
|
+
values,
|
|
1808
|
+
fields,
|
|
1809
|
+
filters,
|
|
1810
|
+
lambda,
|
|
1811
|
+
tests: renderTestsSection()
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
var LLMS_TXT = "llms.txt";
|
|
1815
|
+
var LLMS_FULL_TXT = "llms-full.txt";
|
|
1816
|
+
function renderTopic(t) {
|
|
1817
|
+
const body = [...t.body];
|
|
1818
|
+
if (t.dropLeadingHeading) {
|
|
1819
|
+
body.shift();
|
|
1820
|
+
while (body.length > 0 && body[0] === "") body.shift();
|
|
1821
|
+
}
|
|
1822
|
+
while (body.length > 0 && body[body.length - 1] === "") body.pop();
|
|
1823
|
+
return [`# ${t.title}`, "", `> ${t.readWhen}`, "", ...body, ""].join("\n");
|
|
1824
|
+
}
|
|
1825
|
+
function renderDocs(m) {
|
|
1826
|
+
const s = renderSections(m);
|
|
1827
|
+
const topics = [
|
|
1828
|
+
{
|
|
1829
|
+
path: "llms/object-kinds.md",
|
|
1830
|
+
title: "Object kinds",
|
|
1831
|
+
readWhen: "Read when you need the full catalog of authorable primitives \u2014 which factory builds each, which register method takes it, and which payload key it lands under. Read it too when a `register*` call will not typecheck because the array was built by `.flatMap()`/`.concat()` across modules.",
|
|
1832
|
+
body: s.objectKinds,
|
|
1833
|
+
dropLeadingHeading: true
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
path: "llms/kinds-core.md",
|
|
1837
|
+
title: "Core def shapes",
|
|
1838
|
+
readWhen: "Read when authoring a function, query, api group, task, workflow test, middleware, or tool \u2014 and for the `response` and `expr` shapes every one of them uses.",
|
|
1839
|
+
body: s.kindsCore,
|
|
1840
|
+
dropLeadingHeading: true
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
path: "llms/tests.md",
|
|
1844
|
+
title: "Saved unit tests, assertions, and mocks",
|
|
1845
|
+
readWhen: "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.",
|
|
1846
|
+
body: s.tests,
|
|
1847
|
+
dropLeadingHeading: true
|
|
1848
|
+
},
|
|
1849
|
+
{
|
|
1850
|
+
path: "llms/kinds-agent-mcp.md",
|
|
1851
|
+
title: "Agent and MCP def shapes",
|
|
1852
|
+
readWhen: "Read when the workspace defines an `agent()` or an `mcpServer()`.",
|
|
1853
|
+
body: s.kindsAgentMcp
|
|
1854
|
+
},
|
|
1855
|
+
{
|
|
1856
|
+
path: "llms/kinds-realtime.md",
|
|
1857
|
+
title: "Realtime def shapes",
|
|
1858
|
+
readWhen: "Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.",
|
|
1859
|
+
body: s.kindsRealtime
|
|
1860
|
+
},
|
|
1861
|
+
{
|
|
1862
|
+
path: "llms/triggers.md",
|
|
1863
|
+
title: "Triggers",
|
|
1864
|
+
readWhen: "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.",
|
|
1865
|
+
body: s.triggers,
|
|
1866
|
+
dropLeadingHeading: true
|
|
1867
|
+
},
|
|
1868
|
+
{
|
|
1869
|
+
path: "llms/statements-data.md",
|
|
1870
|
+
title: "Array and database statements",
|
|
1871
|
+
readWhen: "Read when the stack reads or writes rows (`s.db.*`), or transforms an array in place (`s.array.map`, `s.array.union`).",
|
|
1872
|
+
body: s.stmtData
|
|
1873
|
+
},
|
|
1874
|
+
{
|
|
1875
|
+
path: "llms/statements-runtime.md",
|
|
1876
|
+
title: "Statement runtime behavior",
|
|
1877
|
+
readWhen: "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.",
|
|
1878
|
+
body: s.stmtRuntime
|
|
1879
|
+
},
|
|
1880
|
+
{
|
|
1881
|
+
path: "llms/statements-calls.md",
|
|
1882
|
+
title: "Auth, cross-object calls, and microservices",
|
|
1883
|
+
readWhen: "Read when the stack authenticates, calls another object (`s.function.run`, `s.api.request`, `s.tool.call`), or reaches a microservice.",
|
|
1884
|
+
body: s.stmtCalls
|
|
1885
|
+
},
|
|
1886
|
+
{
|
|
1887
|
+
path: "llms/values.md",
|
|
1888
|
+
title: "Value catalog",
|
|
1889
|
+
readWhen: "Read when you need a literal, a reference, or a tag you have not used before \u2014 `c.*`, `ref`, `inp`, `auth`, `col`, and what each one encodes to.",
|
|
1890
|
+
body: s.values,
|
|
1891
|
+
dropLeadingHeading: true
|
|
1892
|
+
},
|
|
1893
|
+
{
|
|
1894
|
+
path: "llms/fields.md",
|
|
1895
|
+
title: "Column and input types",
|
|
1896
|
+
readWhen: "Read when declaring a table column (`f.*`) or a function/query input (`input.*`) \u2014 a type's options and accessor methods, and the `s.precondition` error/status contract that rides the same catalog.",
|
|
1897
|
+
body: s.fields,
|
|
1898
|
+
dropLeadingHeading: true
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
path: "llms/filters.md",
|
|
1902
|
+
title: "Filter catalog",
|
|
1903
|
+
readWhen: "Read when piping a value through `fl.*` \u2014 the full catalog with each filter's argument types.",
|
|
1904
|
+
body: s.filters,
|
|
1905
|
+
dropLeadingHeading: true
|
|
1906
|
+
},
|
|
1907
|
+
{
|
|
1908
|
+
path: "llms/lambda.md",
|
|
1909
|
+
title: "Lambda bodies (JavaScript)",
|
|
1910
|
+
readWhen: "Read when writing a JavaScript body, or weighing whether to reach for one at all \u2014 `lam.fn`, `fl.lambda`, `fl.reduce`, `s.lambda`. Lambda workers are a bounded shared resource, each surface binds a different set of identifiers, and the hazards are not guessable.",
|
|
1911
|
+
body: s.lambda,
|
|
1912
|
+
dropLeadingHeading: true
|
|
1913
|
+
},
|
|
1914
|
+
{
|
|
1915
|
+
path: "llms/legacy.md",
|
|
1916
|
+
title: "Legacy paradigms and retired statements",
|
|
1917
|
+
readWhen: 'Read when the code was PULLED from an existing Xano instance rather than authored here \u2014 how a codegen\'d tree reads and the three shapes in it that must not be "fixed", plus a `raw({ name: "mvp:\u2026" })` statement, a `realtimeTrigger()`, or any name the catalogs do not list. None of the retired names are authorable for new code.',
|
|
1918
|
+
body: s.legacy,
|
|
1919
|
+
dropLeadingHeading: true
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
path: "llms/statements-catalog.md",
|
|
1923
|
+
title: "Statement catalog",
|
|
1924
|
+
readWhen: "Read for the field signature of a specific statement \u2014 every surface, grouped by `s.*` namespace. Look here after the control-flow core in the router does not cover what you need.",
|
|
1925
|
+
body: s.stmtCatalog
|
|
1926
|
+
}
|
|
1927
|
+
];
|
|
1928
|
+
const nav = [
|
|
1929
|
+
"## Topic files",
|
|
1930
|
+
"",
|
|
1931
|
+
"Each line is a condition on the task. Open the files whose condition matches and",
|
|
1932
|
+
"skip the rest \u2014 paths are relative to this file (`node_modules/@xanots/sdk/` once",
|
|
1933
|
+
"installed), so they resolve with a plain file read and always match the version you",
|
|
1934
|
+
`have. \`${LLMS_FULL_TXT}\` is this file and every one below concatenated \u2014 for a reader`,
|
|
1935
|
+
"that wants one fetch, not for an agent that can open the two files it needs.",
|
|
1936
|
+
"",
|
|
1937
|
+
...topics.map((t) => `- [${t.title}](${t.path}): ${t.readWhen}`),
|
|
1938
|
+
""
|
|
1939
|
+
];
|
|
1940
|
+
const slot = s.router.indexOf(NAVIGATION_SLOT);
|
|
1941
|
+
if (slot === -1) throw new Error(`the router lost its ${NAVIGATION_SLOT} placeholder`);
|
|
1942
|
+
const router = [...s.router.slice(0, slot), ...nav, ...s.router.slice(slot + 1)];
|
|
1943
|
+
const docs = /* @__PURE__ */ new Map([[LLMS_TXT, router.join("\n")]]);
|
|
1944
|
+
for (const t of topics) docs.set(t.path, renderTopic(t));
|
|
1945
|
+
docs.set(
|
|
1946
|
+
LLMS_FULL_TXT,
|
|
1947
|
+
[docs.get(LLMS_TXT) ?? "", ...topics.map((t) => docs.get(t.path) ?? "")].join("\n")
|
|
1948
|
+
);
|
|
1949
|
+
return docs;
|
|
1950
|
+
}
|
|
1951
|
+
function renderLlmsTxt(m) {
|
|
1952
|
+
const txt = renderDocs(m).get(LLMS_TXT);
|
|
1953
|
+
if (txt === void 0) throw new Error(`renderDocs emitted no ${LLMS_TXT}`);
|
|
1954
|
+
return txt;
|
|
1955
|
+
}
|
|
1956
|
+
export {
|
|
1957
|
+
FIELD_METHODS,
|
|
1958
|
+
FILTER_NAMES,
|
|
1959
|
+
GENERATED_SPECS,
|
|
1960
|
+
GENERATED_STATEMENT_NAMES,
|
|
1961
|
+
IMPLEMENTED_STATEMENTS,
|
|
1962
|
+
LLMS_TXT,
|
|
1963
|
+
LOCK_VERSION,
|
|
1964
|
+
PAYLOAD_ARRAY_KEYS,
|
|
1965
|
+
QUERY_EXPRESSION_FILTERS,
|
|
1966
|
+
REFERENCEABLE_KINDS,
|
|
1967
|
+
STATEMENT_SURFACES,
|
|
1968
|
+
TOTAL_OBJECT_KINDS,
|
|
1969
|
+
TOTAL_STATEMENTS,
|
|
1970
|
+
VECTOR_FILTERS,
|
|
1971
|
+
WORKSPACE_KEY,
|
|
1972
|
+
addonKind,
|
|
1973
|
+
adoptFromBundle,
|
|
1974
|
+
agentKind,
|
|
1975
|
+
apiGroupKind,
|
|
1976
|
+
buildBundle,
|
|
1977
|
+
buildManifest,
|
|
1978
|
+
buildMiddlewareBlock,
|
|
1979
|
+
buildWorkspaceHistory,
|
|
1980
|
+
calcSignatureJson,
|
|
1981
|
+
channelPathParams,
|
|
1982
|
+
createLockContext,
|
|
1983
|
+
declaredServicePorts,
|
|
1984
|
+
deriveGuid,
|
|
1985
|
+
emptyLock,
|
|
1986
|
+
encodeAddon,
|
|
1987
|
+
encodeAgent,
|
|
1988
|
+
encodeApiGroup,
|
|
1989
|
+
encodeColumn,
|
|
1990
|
+
encodeContainerHistory,
|
|
1991
|
+
encodeFromSpec,
|
|
1992
|
+
encodeFunction,
|
|
1993
|
+
encodeHistory,
|
|
1994
|
+
encodeIndex,
|
|
1995
|
+
encodeMcpServer,
|
|
1996
|
+
encodeMicroservice,
|
|
1997
|
+
encodeMiddleware,
|
|
1998
|
+
encodeMiddlewareEntry,
|
|
1999
|
+
encodeMiddlewareList,
|
|
2000
|
+
encodeObject,
|
|
2001
|
+
encodeQuery,
|
|
2002
|
+
encodeRealtimeChannel,
|
|
2003
|
+
encodeRealtimeMessage,
|
|
2004
|
+
encodeRealtimeServer,
|
|
2005
|
+
encodeResponse,
|
|
2006
|
+
encodeSchedule,
|
|
2007
|
+
encodeStatement,
|
|
2008
|
+
encodeTable,
|
|
2009
|
+
encodeTask,
|
|
2010
|
+
encodeTool,
|
|
2011
|
+
encodeToolRefs,
|
|
2012
|
+
encodeToolsetBase,
|
|
2013
|
+
encodeTrigger,
|
|
2014
|
+
encodeView,
|
|
2015
|
+
encodeWorkflowTest,
|
|
2016
|
+
encodeWorkspaceConfig,
|
|
2017
|
+
functionKind,
|
|
2018
|
+
generated as generatedStatements,
|
|
2019
|
+
getKind,
|
|
2020
|
+
getLockedCanonical,
|
|
2021
|
+
getLockedGuid,
|
|
2022
|
+
getStatementFactory,
|
|
2023
|
+
isLockSeeded,
|
|
2024
|
+
isQueryExpressionFilter,
|
|
2025
|
+
isRegisteredKind,
|
|
2026
|
+
isRegisteredStatement,
|
|
2027
|
+
lockKey,
|
|
2028
|
+
mcpServerKind,
|
|
2029
|
+
mergeObserved,
|
|
2030
|
+
microserviceKind,
|
|
2031
|
+
middlewareKind,
|
|
2032
|
+
mintCanonical,
|
|
2033
|
+
parseLock,
|
|
2034
|
+
phpJsonEncode,
|
|
2035
|
+
queryKind,
|
|
2036
|
+
realtimeChannelGuid,
|
|
2037
|
+
realtimeChannelKind,
|
|
2038
|
+
realtimeMessageGuid,
|
|
2039
|
+
realtimeMessageKind,
|
|
2040
|
+
realtimeServerKind,
|
|
2041
|
+
recordObserved,
|
|
2042
|
+
registerKind,
|
|
2043
|
+
registerSpec,
|
|
2044
|
+
registeredKinds,
|
|
2045
|
+
renameLockEntry,
|
|
2046
|
+
renderDocs,
|
|
2047
|
+
renderLlmsTxt,
|
|
2048
|
+
resolveAuthRef,
|
|
2049
|
+
resolvePayloadKey,
|
|
2050
|
+
resolveRealtimeServerCanonical,
|
|
2051
|
+
resolveRef,
|
|
2052
|
+
resolveToolsetCanonical,
|
|
2053
|
+
sPathOf,
|
|
2054
|
+
serializeBundle,
|
|
2055
|
+
serializeLock,
|
|
2056
|
+
tableKind,
|
|
2057
|
+
taskKind,
|
|
2058
|
+
toNestedFields,
|
|
2059
|
+
toolKind,
|
|
2060
|
+
triggerKind,
|
|
2061
|
+
validateLockModel,
|
|
2062
|
+
workflowTestKind,
|
|
2063
|
+
workspaceKind
|
|
2064
|
+
};
|
|
2065
|
+
//# sourceMappingURL=internal.js.map
|