Privatium Tier 1 — Lua

A Tier 1 app is app.toml, app.lua, and views/*.lsp. No build step. Save a file, refresh the browser, see the change.

Skeleton

-- app.lua
local pv = require 'privatium'

pv.get('/', function(req)
  return pv.render('index', {
    fills = pv.query('SELECT * FROM fill ORDER BY filled_on DESC LIMIT 50')
  })
end)

pv.post('/fill', function(req)
  pv.append('fill', {
    drug         = req.form.drug,
    filled_on    = req.form.filled_on,
    copay_amount = req.form.copay_amount,   -- string, stays a string
  })
  return pv.redirect(url('/'))
end)
<!-- views/index.lsp -->
<h1>Fills</h1>
<? for _, f in ipairs(fills) do ?>
  <li><?= f.drug ?> — <?= fmt.money(f.copay_amount) ?></li>
<? end ?>

LSP tags

Tag Meaning
<? ... ?> Execute, emit nothing
<?= expr ?> Emit, HTML-escaped
<?raw expr ?> Emit unescaped — every use is a review trigger
<?-- ... --?> Comment

Helpers in every template: render, layout, menu, icon, url, fmt.date, fmt.money, fmt.rel, csrf, t.

Moving between pages

[ui] navigation = "swap" in app.toml makes your app’s pages change inside one document: a link or form in the page fetches the next page, and the frame swaps only its <main> in, so the bar never moves and nothing flashes. Turn it on when people move between your pages often, on a phone, and the flash between them is noticeable. Leave the default, "page", when your pages rarely change or a view owns its document with layout().

What the frame does, so your app need not: it boosts the main region, keeps the request inside your mount and off /settings, /api, /skills, /static and /ws — anything else is a fresh page, as before — follows a form’s redirect inside the document, takes the new page’s window title, refreshes the menu’s app items, disables a form’s submit button while it is out, scrolls to the top, and focuses the page’s autofocus field or else its <h1>. The back button reloads the page you return to. Your routes do not change: a boosted request is answered with the whole page, and req.is_htmx is false for it.

What changes for you: a swapped page runs no <script> and loads no <link rel="stylesheet"> of its own. Name every script and stylesheet in [ui] scripts and [ui] styles, which the frame’s head loads once, and write scripts that never assume a fresh page. The linter refuses a <script> or stylesheet link in a view of a swap app (PV111). A link to a page whose view calls layout() carries hx-boost="false", so it opens as its own document.

[ui]
navigation = "swap"
styles     = ["static/app.css"]
scripts    = ["static/app.js"]
// static/app.js — loaded once; pages come and go beneath it.
// Delegate from the document: it works for every page that is ever swapped in.
document.addEventListener('click', event => {
  const button = event.target.closest('[data-copy]');
  if (button) navigator.clipboard.writeText(button.dataset.copy);
});

// Or set up what needs an element on each new page, once per element.
document.addEventListener('htmx:load', event => {
  for (const chart of event.detail.elt.querySelectorAll('[data-chart]:not([data-ready])')) {
    chart.dataset.ready = '';
    drawChart(chart);
  }
});

htmx:load fires for the first page and for every swapped one, so the guard attribute is what stops an element from being set up twice. A script that does its work at the top level, once, works on the first page and silently never again.

pv.on('append') also receives accepted events from other devices. Its VM is checked out by the drain, outside the node lock, and pv.device() names the origin device. Guard callbacks against append loops as you do for local events.

MUST

MUST NOT

Anti-patterns

-- WRONG: SQL injection, and it will not even be reached — the linter rejects it
pv.query("SELECT * FROM fill WHERE drug = '" .. req.form.drug .. "'")
-- RIGHT
pv.query('SELECT * FROM fill WHERE drug = ?', {req.form.drug})

-- WRONG: money as a float. 0.1 + 0.2 ~= 0.3
local total = tonumber(a.copay_amount) + tonumber(b.copay_amount)
-- RIGHT
local total = pv.dec(a.copay_amount) + pv.dec(b.copay_amount)

-- WRONG: breaks in solo mode
return pv.redirect('/a/medtracker/')
-- RIGHT
return pv.redirect(url('/'))

-- WRONG: two events, one can land without the other
pv.append('node', a, {...}); pv.append('node', b, {...})
-- RIGHT
pv.batch(function(tx) tx.append('node', a, {...}); tx.append('node', b, {...}) end)

-- WRONG: mutation
pv.query('UPDATE fill SET copay_amount = ? WHERE id = ?', {x, id})
-- RIGHT
pv.append('fill', id, { copay_amount = x })

-- WRONG: XSS
<?raw req.form.note ?>
-- RIGHT
<?= req.form.note ?>

Schema

schema.sql is optional. Include it when you want typed tables and SQL queries; omit it to use the log as a document store. Every table needs id VARCHAR PRIMARY KEY, holding a ULID — unless the table is a singleton you key by a constant, the way apps/animals keys its cursor row 'cursor'. Use DECIMAL(18,2) for money and DATE for dates — never text.

Changing schema.sql rematerializes from the logs. This is safe at any time and loses nothing; new columns are simply NULL for old events.

Escalate

If the app needs canvas, WebGL, animation, or a custom interaction model, stop and load privatium-tier2-web. Do not fight LSP into being a game engine.

Verify

privatium new <slug>            # an empty app; --from hello copies the reference app,
                                # --scaffold <table> emits CRUD screens for a table
privatium dev --app <slug>      # runs it; a save is served on the next request, no restart
                                # --open uses the local browser URL; LAN callers need pairing
privatium lint apps/<slug>      # exit 3 on findings; --format json to read them back
privatium lint apps/<slug> --fix   # only url() for a literal mount path and focusable="false"

Full API: spec/lua-api.md, and reference/pv-api.md here for the surface this version registers. CLI and lint rules: spec/cli.md; reference/anti-patterns.md shows every Tier 1 rule failing and passing.