How the platform actually executes: from a click in the browser to a screen, and the two mechanisms that surprise every new developer.
SELECT; Module 8 gives you the queries.
Most stacks spread a feature across five technologies. TSQL.APP collapses that: the database is the application. The other two layers are deliberately incapable of holding business logic.
In SQL: either the card's crud_update expression, a field's
on_update_sql, or the action script behind the save button. Nothing is deployed,
nothing is rebuilt — it is rows and procedure text. If your instinct was "add a check in the
API", re-read the diagram: the API cannot see orders at all.
SELECT in your action
script can blank the user's grid.All four HTTP verbs are registered on one catch-all route. The request — path, query string, headers, cookies, body, uploaded files, and every JWT claim — is flattened into a single table-valued parameter and passed to one stored procedure.
dbo.api_main_context_table (id int, grp nvarchar(128), k nvarchar(128), v nvarchar(max))
grp ∈ claim | route | query | header | cookie | body | file | raw | version | database
Route segments are paired, not parsed: /order/42/line/7 becomes
(route,order,42) and (route,line,7). There is no URL grammar anywhere
in the system.
The procedure returns many result sets. The API treats a result set as a name tag for
the one that follows it when it has exactly one row, at most four columns, and a column called
dataset.
SELECT dataset='data' -- marker: "the next result set is called data"
SELECT id, name, amount FROM ... -- the payload
SELECT dataset='toasts'
SELECT * FROM #toasts
data — and silently overwrites the real grid. One debugging
SELECT * FROM #tmp left in an action script is enough. If a list goes empty after
your edit, look for an unlabelled SELECT before you look anywhere else.| Dataset | What the client does with it |
|---|---|
views | The render manifest: (comp, data) pairs. The server decides which components exist. |
data / def | Rows, and the column metadata describing them. |
data_modalview / def_modalview | The modal stream, and the echoed request body — see Module 5. |
settings, breadcrumbs, statusbar, toasts | Chrome. |
querystring | Server-driven navigation — pathname triggers a pushState. |
merge | "Merge into existing state, don't replace" — how a POST returns only a modal. |
file, raw | Terminal payloads: a file download or an arbitrary content type. |
sp_api_start does for youBefore your action script executes, the kernel has already built the response buffer. These temp tables exist for the life of the request, and writing to them is how you produce output:
| Table | Written by | Becomes |
|---|---|---|
#modalview | every sp_api_modal_* | data_modalview |
#toasts | sp_api_toast | toasts |
#statusbar | sp_api_statusbar | statusbar |
#querystring | sp_api_goto | querystring |
#reactviews | the renderers | views |
#context | the kernel (read-only to you) | the whole request |
#modalview, not #modal. The toast buffer is
#toasts, plural. Guessing either wastes an afternoon.Also already done for you: the bearer token has been scrubbed out of
#context, every ?foo=bar is in
SESSION_CONTEXT('query_foo'), claims are folded into
user_claims and user_roles, and the business database name is derived
by a single string slice:
@main_database = SUBSTRING(DB_NAME(), 0, CHARINDEX('_proj', DB_NAME(), 0))
-- app_proj → app. That one line is the entire multi-tenant model.
There is no routing table. sp_api_card_list takes the next route row from
#context, resolves it against api_card, and calls itself
for the segment after it. Tabs, breadcrumbs and data scoping all fall out of that one recursion.
/order/42/line/7/allocation
│ │ │ │ └── no id → Listview (the leaf)
│ │ │ └───────── id 7 → Detailview, then recurse
│ │ └──────────── card "line", bound to parent via api_card_children.ref
│ └───────────────── id 42 → Detailview, then recurse
└──────────────────────── card "order"
The card lookup applies the ACL inline:
FROM api_card ac CROSS APPLY tvf_hasRole_v3(ac.role, ac.deny_role)
"Nothing to do here", not an execution.This is the mechanism that surprises everyone, including AI agents asked to describe it. A script can ask the user a question and appear to carry on afterwards. SQL Server has no coroutines. So how?
EXEC sp_api_modal_text N'Set Record Limit', N'h4 text-muted'
EXEC sp_api_modal_input @name='@limit', @value = @limit OUT, @focus=1
EXEC sp_api_modal_button @name='@button', @value='Set', @valueout = @button OUT, @key='Enter'
IF @button IS NULL RETURN -- ← the "await". Your code, not the framework's.
UPDATE api_card SET record_limit = TRY_CAST(@limit AS int) WHERE id IN ({@ids})
EXEC sp_api_modal_clear
sp_api_modal_input and sp_api_modal_button never abort. They render,
then prefer the posted answer over their input:
EXEC sp_api_modal_get_value @name, @value = @postvalue OUT
IF @postvalue is not null
BEGIN
SET @valueout = @postvalue
END
Before the click @valueout is NULL; after, it holds the caption.
The suspension is entirely your IF … RETURN.
INSERT, an UPDATE or an EXEC sp_api_email placed before the
guard fires once per step of an N-step wizard. Put writes after the final guard, or make
them idempotent.sp_api_modal_input @name='@x' in one script share one answer. Disambiguate with
@identifier or @seed — that is exactly how loops over rows work.They put EXEC sp_api_email above the final guard, in a three-step flow. It ran on
every replay. Move it below the last IF … IS NULL RETURN. If it must stay high in
the script, guard it on the answer that only exists on the final pass.
sp_api_modal_clear appear at the top of most scripts?Because the replay re-renders every element each pass. Without clearing, round trip 2 stacks its elements underneath round trip 1's, and the modal grows every time the user answers something.
EXEC #nameA button body normally runs as a batch — EXEC sp_executesql @sql, … — not
as a procedure. But a script can call another action as a subroutine by writing
EXEC #name, and that changes the picture: such a script is rewritten so that
at runtime it dynamically generates and runs a temporary stored procedure.
api_actions rows whose name begins with
#. The hash is part of the stored name, because the scanner slices it out of
your script text and looks it up verbatim. A mature app typically grows a small library of them —
#alert, #h2, #report_footer, #lookup,
#ledger.@user_name, @user_id, @card_name,
@parent_card_name and @parent_parent_id from
SESSION_CONTEXT inside the generated body.| When | What is built | Lifetime |
|---|---|---|
| save | [spca_<card>_<action>] | created and dropped immediately — a syntax check, nothing more |
| save | the EXEC #name generator | permanent, as text inside the stored script |
| run | #name, built from api_actions | the batch |
The button body itself is never any of these. It is the batch.
sp_ask_ai_button_write, which stores sql and unparsed_sql as
the same text. A button written that way containing EXEC #foo gets no generator,
compiles clean anyway because SQL Server defers name resolution for
EXEC, and fails only when a human presses it:
Could not find stored procedure '#foo'. Compile-clean is not correct. If you author
outside the designer, either inline the logic or verify the generator text is present in the
stored sql afterwards.About 1% of action bodies, on a demo database and on a large production app alike. Low share — but on the real app those are a maintained helper library called from production buttons, not an oddity. Judge a feature's importance on a real customer database, never on a demo one: a demo app will understate both how much a feature is used and what it is used for.
One transaction wraps the whole request, including the entire replayed script. Severity is used as a control-flow channel, not as an error level:
| You raise | The kernel does |
|---|---|
RAISERROR('success',13,1) | stop, commit, render what is in #modalview |
RAISERROR('no-success',13,1) | stop, roll back, still render the modal |
RAISERROR('Heading|Detail',11,1) | red error dialog, split on the pipe; not emailed |
| severity 14 | swallowed silently |
| anything else | real crash: roll back, log to api_errors |
BEGIN TRAN without COMMIT leaves the count mismatched and the request's
writes are lost when the pooled connection resets. A bare ROLLBACK kills the kernel's
transaction, after which every IF @@TRANCOUNT > 0 guard — including the final
commit — is skipped. Use neither; signal with success / no-success.RAISERROR('success',13,1) is a control signal. Wrap a modal helper in your own
TRY…CATCH and your handler eats it, stalling the flow.Read a procedure in full (-y 0 prevents truncation; note -h and
-y are mutually exclusive in sqlcmd):
sqlcmd -S <server> -d <yourapp>_proj -y 0 \
-Q "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.sp_api_card_actions_execute'))"
| To prove | Run |
|---|---|
| Collectors prefer the posted answer | SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.sp_api_modal_button')) |
| State is per-request only | SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.sp_api_modal_get_value')) |
| The guard idiom is real | SELECT TOP 5 name, [sql] FROM api_card_actions WHERE [sql] LIKE '%IF @button IS NULL RETURN%' |
| Replay, not resume | sp_api_modal_values_clear exists only to forget answers — meaningless if state were resumed |
| Who carries the generator | SELECT COUNT(*) FROM api_card_actions WHERE [sql] LIKE '%temp_sp_sql_xqr%' |
| The helper library | SELECT name, [global] FROM api_actions WHERE name LIKE '#%' |
| Which action-menu build is live | SELECT [value] FROM api_settings WHERE [key]='sp_api_card_actions_version' |
EXEC sp_api_modal_debug once inside a script. The kernel records the inbound
api_action_post and, at the end of the request, the entire outbound
#modalview on the same api_action_debug row — a full request/response
tape of the replay.SELECT becomes data.EXEC #name means the batch builds and runs a temp procedure — and compile-clean is not correct.