TSQL.APP — Technical Training

How the platform actually executes: from a click in the browser to a screen, and the two mechanisms that surprise every new developer.

Self-contained course · written against a live demo database and a large production app · 2026-08-10.

Who this is for You can write T-SQL and you are about to write or review a TSQL.APP action script. You do not need to know C# or React — you will see why by the end of Module 1. Every claim here is checkable with a SELECT; Module 8 gives you the queries.

1The shape of the system

Objective Explain, in one sentence each, what the API, the client and the database are responsible for — and predict where a given feature's logic must live.

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.

React SPA renders a manifest .NET API :8080 ~877 lines, no logic app_proj every decision app business data synonyms tsqlapp-dispatcher (Node) email · pdf · excel · sftp · print Broker the URL in the address bar IS the API URL flattens the request into one table-valued parameter
Only the shaded box is allowed to decide anything.
The practical consequence When someone asks "how does feature X work", the answer is a stored procedure or an action script. Reading the C# will tell you almost nothing — it is the same 877 lines for every customer and every app.
Check yourself — a customer wants a new validation rule on saving an order. Where does it go?

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.

2The wire: one table in, named datasets out

Objective Read a raw request and response, and explain why a stray SELECT in your action script can blank the user's grid.

Inbound: everything becomes rows

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.

Outbound: a marker row names the next result set

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
The classic blank screen A result set with no marker in front of it falls through with the default name 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.
DatasetWhat the client does with it
viewsThe render manifest: (comp, data) pairs. The server decides which components exist.
data / defRows, and the column metadata describing them.
data_modalview / def_modalviewThe modal stream, and the echoed request body — see Module 5.
settings, breadcrumbs, statusbar, toastsChrome.
querystringServer-driven navigation — pathname triggers a pushState.
merge"Merge into existing state, don't replace" — how a POST returns only a modal.
file, rawTerminal payloads: a file download or an arbitrary content type.

3The kernel: what sp_api_start does for you

Objective Name the request-scoped scratch tables your script writes into, and know which session values are already set before your code runs.

Before 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:

TableWritten byBecomes
#modalviewevery sp_api_modal_*data_modalview
#toastssp_api_toasttoasts
#statusbarsp_api_statusbarstatusbar
#querystringsp_api_gotoquerystring
#reactviewsthe renderersviews
#contextthe kernel (read-only to you)the whole request
Two naming traps The modal buffer is #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.

4Routes are a recursion

Objective Predict what renders for a nested URL, and why an unauthorised card behaves like a typo.

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)
Why that matters No matching role means zero rows, which is indistinguishable from a card that does not exist. Unauthorised screens are not refused — they are absent. The same idea protects buttons: forging an action POST finds no row and produces the toast "Nothing to do here", not an execution.

5The screenplay: replay, not coroutines

Objective Explain why a wizard works without any server-side state, and place your writes so they run exactly once.

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?

The whole answer There is no resume point. The script is re-executed from the top on every round trip. The answers given so far live in a JSON bag that travels to the browser and back. "Step N" is not stored anywhere — it is the first question whose name is not yet in the bag.

A two-step button, and what actually happens

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
Round trip 1 modal_text → #modalview modal_input → #modalview modal_button→ #modalview IF @button IS NULL RETURN ships browser shows the modal user types 25, clicks Set bag posted back: {"values":{ "@limit":"25", "@button":"Set" }} same URL, whole bag re-POSTed Round trip 2 — the same script, from the top modal_text → renders again modal_input → finds "@limit" → @limit = 25 modal_button→ finds "@button" → @button = 'Set' IF @button IS NULL RETURN ← passes now UPDATE api_card ... ← runs, once Where the answers were found: #context grp='body' k='api_action_post' → shredded into #sp_api_modal_get_value(key,value)
Nothing was remembered on the server. The bag came back and the script ran again.

Why the collectors cooperate

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.

The rule that follows from all of this Everything above your first unanswered modal runs again on every step. An 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.

Two more consequences

Check yourself — a colleague reports their wizard sends three confirmation emails. What did they do?

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.

Check yourself — why does 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.

6The subroutine convention: EXEC #name

Objective Recognise a script that generates a temporary stored procedure at runtime, know where the helper library lives, and avoid the authoring trap.

A 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.

SAVE TIME — once, when the button is stored what you typed EXEC #ledger SELECT ... scan sp_api_card_actions_ execute_prepare_sql finds each EXEC #name stored in api_card_actions.sql <generator> EXEC #ledger ... RUN TIME — on every request, including every replay step EXEC('DROP PROC IF EXISTS #ledger'); SET @temp_sp_sql_xqr = (SELECT CONCAT(N'CREATE PROC #ledger', pre_declare, 'AS BEGIN', <SESSION_CONTEXT DECLAREs>, sql, 'END') FROM api_actions WHERE name = '#ledger') EXEC(@temp_sp_sql_xqr) -- the temp proc now exists for the rest of the batch EXEC #ledger -- your call finally resolves
The rewrite happens once, at save. The generation happens on every execution.

Three facts that are easy to get wrong

Three different moments build three different procedures

WhenWhat is builtLifetime
save[spca_<card>_<action>]created and dropped immediately — a syntax check, nothing more
savethe EXEC #name generatorpermanent, as text inside the stored script
run#name, built from api_actionsthe batch

The button body itself is never any of these. It is the batch.

The authoring trap Some write paths do not run the save-time scanner — notably 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.
Check yourself — how common is this convention, really?

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.

7Transactions and the severity channel

Objective Stop a script cleanly, show a titled error, and never manage a transaction yourself.

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 raiseThe 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 14swallowed silently
anything elsereal crash: roll back, log to api_errors
Do not open your own transaction 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.
And beware your own TRY/CATCH 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.

8Lab: prove it yourself

Objective Never take this document's word for anything.

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 proveRun
Collectors prefer the posted answerSELECT OBJECT_DEFINITION(OBJECT_ID('dbo.sp_api_modal_button'))
State is per-request onlySELECT OBJECT_DEFINITION(OBJECT_ID('dbo.sp_api_modal_get_value'))
The guard idiom is realSELECT TOP 5 name, [sql] FROM api_card_actions WHERE [sql] LIKE '%IF @button IS NULL RETURN%'
Replay, not resumesp_api_modal_values_clear exists only to forget answers — meaningless if state were resumed
Who carries the generatorSELECT COUNT(*) FROM api_card_actions WHERE [sql] LIKE '%temp_sp_sql_xqr%'
The helper librarySELECT name, [global] FROM api_actions WHERE name LIKE '#%'
Which action-menu build is liveSELECT [value] FROM api_settings WHERE [key]='sp_api_card_actions_version'
The best debugger in the platform Call 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.
Measure usage on a real app, not on a demo A demo database may carry a few hundred action scripts where a live business app carries a few thousand. Any count you take from a demo will understate how a feature is really used — and, more misleadingly, will hide what it is used for. Run these counts against a production database before drawing a conclusion from them.

The five sentences worth memorising

  1. The database is the application; the API and the client cannot hold logic.
  2. One table goes in, named result sets come out — and an unlabelled SELECT becomes data.
  3. An action script is replayed from the top on every round trip; its state travels through the browser.
  4. Put your writes after the final guard, or make them idempotent.
  5. EXEC #name means the batch builds and runs a temp procedure — and compile-clean is not correct.