Tables a site declares and holds - a product list, an events calendar, a directory - read on a page like any other variable.
A data table is a set of records the site owns: a product list, an events calendar, a staff directory. You describe its shape in a small YAML file, the engine keeps the records, and a page reads them through the same page-variable mechanism it uses for everything else.
Tables hold site data. Per-visitor state -- a session, a shopping basket, somebody's profile -- is an application, and deliberately out of scope.
The feature ships disabled. Enable Data tables on the Extensions page before anything below will answer.
A table is described by YAML. You do not write that file directly: it lives
under lazysite/, which holds the account store and the session secret, and
every general write channel refuses that area on purpose. Use the door built
for it, which also checks the descriptor before storing it:
save_data_table with table and descriptorPOST ?action=data-table-save with
{"table": "products", "descriptor": "…yaml…"}title: Products
key: code
fields:
code:
type: text
required: true
max: 20
name:
type: text
price:
type: decimal
digits: 8
places: 2
in_stock:
type: boolean
default: true
The table name becomes the filename and must be lower-case letters, digits and underscores. A descriptor that does not load is refused with the field and the reason, and nothing is written -- so a refusal here is information rather than a failure.
timestamps: true adds created_at and updated_at (UTC, to the
second) and created_by and updated_by (the signed-in account's login;
empty for a public form), stamped by the extension on every insert and update
and refused from any writer -- the one provenance a reader can trust,
because no caller can supply it. A field you declare yourself named like a
stamp (added_at, added_by) is whatever the writer sends, and the table's
description says so. A table that had the flag before the author columns
existed gains them as an additive migration: data-migrate-plan lists
them, data-migrate adds them.
Declaring a table does not create it. Run the migration once:
migrate_data_tablePOST ?action=data-migrate&table=productsIt is safe to re-run: a table already in line is a no-op. It applies what is safe and reports what it refuses, and the refused list is the useful half -- it is the account of why a column is not there yet.
To see that account before anything is applied, ask for the plan:
plan_data_migrationGET ?action=data-migrate-plan&table=productsIt runs the same planner the migration does and changes nothing, so the
preview and the action cannot disagree. To edit a descriptor as the text you
wrote it -- comments, key order and spacing intact -- read it back with
read_data_table_source (MCP) or data-table-source (Control API), change
it, and save it again.
save_data_row, read_data_rows, delete_data_rowdata-row-save, data-rows, data-row-deleteA save has two shapes, and the difference is where the key goes:
row, and there is no top-level key.key, and it is not repeated inside
row. Repeating it is refused as "'slug' is the row's key and cannot be
changed by an update": a key identifies the row, so an update cannot also
set it.On a page:
---
title: Our products
tt_page_var:
products: db:products(order=name,limit=20)
total: db:products.count()
---
and in the layout:
[% total %] products
[% FOREACH p IN products %]<li>[% p.name %] — [% p.price %]</li>[% END %]
| htmlFrom 0.13.0, every value read with db: is HTML-escaped as it reaches the
page. [% p.name %] prints a row holding <b> or <script> as the text it is,
never as markup, so a table anybody can write to cannot put script on a page
that displays it.
So do not add | html. On 0.13 it escapes a second time, and a value
containing &, <, >, " or ' shows as &lt; or ' - quietly,
and only for the rows that happen to contain one, which is how it goes unnoticed
until somebody adds Smith & Jones.
On 0.12 and earlier it was the other way round: values were printed raw, and
| html was the correct advice. A template written correctly for 0.12 is
therefore wrong on 0.13, and the upgrade does not say so. lazysite check lists
every page that binds a table with db: and also uses | html, so those
templates can be found before a visitor finds them. On a site still on 0.12,
either upgrade, or keep markup out of any table a page renders.
There is no switch that turns the escaping off. db_render_raw, which did,
shipped deprecated and was removed in 0.13.13 - no site was found to need it -
and an upgrade deletes the key.
| url_safeEscaping keeps a value inside its attribute; it does not change what the value
says. A row whose url is javascript:alert(1) is still a script link when
it is written straight into an href. Wherever a value is the WHOLE href,
src or action, write it through the filter:
<a href="[% r.url | url_safe %]">[% r.label %]</a>
<img src="[% r.photo | url_safe %]" alt="[% r.label %]">
url_safe passes a value with no scheme (a path, ?query, #fragment or
//host) or one starting http:, https:, mailto: or tel: unchanged, and
turns anything else - javascript:, data:, vbscript:, file: - into #.
It reads the scheme through character references and percent-encoding and
ignores case and leading spaces, and it does not escape the value a second
time. A value with fixed text before it (href="/people/[% r.id %]") takes its
scheme from that text and does not need it.
columns: Written | Means
widths: 7.4cm | X
bold: 1
tone: medium
---
`db:products` | the whole table, declared order
`db:products(featured=true,limit=4)` | filtered, AND-combined
`db:tasks(order=due)` / `(order=-due)` | ordered, ascending or descending
`db:tasks.count(done=false)` | a number, not a list
`db:products.field(price,code=SKU1)` | one value out of one row
`db:applications(mine,status=open)` | the signed-in viewer's own rows -- see *A page that shows each viewer their own rows*
The older spacing -- db:products sort=name asc limit=20 -- still means
exactly the same thing.
You may filter or order on any declared field. Filtering on a field with
no index means the whole table is examined, which for the sizes these tables
are for costs about as much as nothing: measured at 100,000 rows, an unindexed
order by ... limit 10 takes 5.6 ms against 0.03 ms indexed, and a filter on a
common value costs nothing either way because the limit stops it early.
If a read does turn out slow, the log says which binding, how long, and which field to index:
indexes:
- [name]
A compound index [area, street] makes area= cheap and leaves street= a
scan -- an index can only be entered at its first column.
If a table is big enough that an index does not settle it, it has outgrown SQLite rather than outgrown the query, and the answer is a different engine.
Filter values are checked against the field's declared type, so
done=perhaps on a boolean is refused and says so rather than quietly
matching nothing. limit= is capped at 500: ask for more and the binding
clamps to 500 and warns in the render log, naming the page -- serving what
it can beats rendering nothing. With no limit= at all the default is 200,
so a list that outgrows 200 renders short; the render log says so, and the
page can too: every list binding gets a companion <var>_total variable
carrying the true count, so a template can write showing [% items.size %] of
[% items_total %]. .count() is the true count as well -- it ignores any
limit= beside it.
Anything this grammar will not express -- joins, ranges, OR -- is deliberate. Front matter is not a place for a query language.
The data endpoint takes order_by, order, limit and offset in its
query string and runs them through this same grammar, so a page and its own
script cannot be told different things about one table.
columns: Mode | When
widths: 3cm | X
bold: 1
tone: medium
---
`snapshot` | **the default.** Read at render. The page is served from cache for its `ttl:`, and re-rendered on every request if it declares none
`live` | as `snapshot` today -- see the note below. Intended for stock levels and queue lengths
`client` | as `snapshot` today: the rows are still read at render. What makes a page client-side is a `data-ls-db` region (below), which the page's own script fills from the endpoint
stock: db:stock(mode=live)
live and snapshot currently behave identically, and this table used to
say otherwise. It said live was "read on every request, the page is never
cached". That was wrong in a way worth spelling out, because an agent read it,
reasoned correctly from it, and reached a false conclusion.
A table has no timestamp that can prove a cached page still current, so a
binding withdraws the mtime proof of freshness. It does not touch the page's
ttl:, which is a separate mechanism. So a page carrying ttl: 300 is served
from cache for five minutes whatever its mode, unless the table changes (below),
and a page carrying no ttl: is re-rendered every request whatever its mode.
"Never cached" was only ever true of the second case.
Since SM604 every db: binding withdraws that proof - it had to, because
otherwise one json: binding on the same page vouched for the table's
freshness, which nothing can do. That is what leaves the two modes with no
observable difference. mode=live is accepted and parses; it does not change
behaviour today. Do not choose between them on the strength of caching: choose
a ttl:.
A row you change refreshes the pages that show it, at once (from 0.16.8).
Every write the engine makes to a table - a row saved, deleted or imported, on
any door - touches the table's change stamp (lazysite/db/stamps/<table>), and
a page that read the table is rendered again on its next request, whatever its
ttl:: the same as saving the page itself. Nothing depends on the database
file's timestamp, because the store is written through WAL and a row can change
without that timestamp moving. A row changed outside the engine (an sqlite3
shell on the store) touches no stamp, and such a page refreshes when its
ttl: runs out, as before.
A list that changes day to day -- a menu's dishes, the services on this week -- is kept once, and each item is switched on or off. Name the table's boolean field that says so:
title: Menu
key: slug
public: true
availability: today
fields:
slug: { type: text, required: true }
name: { type: text, required: true }
price: { type: decimal, digits: 6, places: 2 }
today: { type: boolean, default: false }
availability must name a declared boolean field; anything else is refused
when the descriptor is saved.
switch_row {table, key, available}; control API POST ?action=data-row-switch
with {"table", "key", "available"}; in the manager, the Available column
of the table's rows. It needs only what writing the row needs: manage_data, or
write_data where the table's writable_by names your group. A row already in
that state is not written.db:menu(available) gives the rows switched on, and the layout
renders them; leave (available) off to have every row with its switch, and let
the layout mark the ones that are off:tt_page_var:
dishes: db:menu(available,order=name)
menu: db:menu(order=name)
[% FOREACH d IN dishes %]<li>[% d.name %] — [% d.price %]</li>[% END %]
[% FOREACH d IN menu %]<li[% IF NOT d.today %] class="off"[% END %]>[% d.name %]</li>[% END %]
(available) on a table that declares no availability field renders nothing and
the log says why, rather than giving every row.
Each row saved, deleted or switched -- in the manager, over the control API, over
MCP, through the data endpoint or by a wizard -- is recorded as one change: when,
who, from where (manager, api, mcp, endpoint, wizard:<name>), what
(insert, update, delete, switch, undo) and the row before and after. The
answer to the write names the change (change).
list_row_changes {table, key, limit}; control API
GET ?action=data-row-changes&table=…; the manager's History button.
manage_data.undo_row_change {table, change}; control API
POST ?action=data-row-undo; Undo in History. An insert is undone by
removing the row, a delete by putting the row back as it was stored (its
authors and times included), anything else by writing back the fields it
changed. The undo is itself a recorded change. It is refused, writing
nothing, when the row changed after that change (stale-change, showing both),
when the change was undone already, or when it is older than the table's
history period (expired-change); it needs what writing the row needs.The changes are kept in the data store itself, in a table named _row_changes
beside the rows. A bulk import, a package restore and a form handler's
rows are not recorded one by one: each has its own record.
A table's changes are kept for 90 days by default. A table sets its own period in its descriptor, in whole days:
title: Specials
key: slug
row_history_days: 30
fields:
# ...
row_history_days is a positive whole number (at most 36500); anything else is
refused by name when the descriptor is saved. describe_data_table and the
history listing show the period in force.
A change older than its table's period is removed, and a deleted row's changes
go the same way, the delete among them. Removal happens in two places, across
every table: when the store is written (each recorded change removes those past
their period), and once a day in the daemon's row-history-prune job, so a
store nobody writes to is cleared too. The job runs as the daemon's job account
(daemon_job_user) and needs nothing beyond run_jobs, so no missing grant
stops it; each run writes one audit row (row-history-prune) saying how many
tables the history held and how many changes went. Reading the history
removes nothing, so a change past its period can still be listed until one of
the two runs; it is shown without an Undo (undoable false) and undoing it is
refused as expired-change.
A wizard asks one question per step and builds, or edits, one row. It is a file,
lazysite/db/wizards/<name>.yaml, saved with MCP save_wizard or control API
data-wizard-save (manage_data), and checked against its table before it is
stored:
title: Add a dish
table: menu
steps:
- field: slug
ask: A short name for its address
type: text
required: true
- field: name
ask: What is the dish called?
type: text
required: true
help: As it should appear on the menu.
- field: price
ask: How much is it?
type: price
- field: spicy
ask: Is it spicy?
type: yesno
- field: heat
ask: How hot?
type: choice
options: [mild, medium, hot]
when: { field: spicy, is: true }
columns: Type | Writes | Takes
widths: 2.6cm | 4cm | X
bold: 1
tone: medium
---
`text` | a text field | the answer as written
`price` | a decimal field | an amount; a currency sign and thousands commas are taken off
`choice` | an enum field, or a text field with `options` | one of the options (an enum's values by default)
`choices` | a text field with `options` | several options, stored comma-separated in the listed order
`yesno` | a boolean field | yes or no
`date` | a date field | a date, `YYYY-MM-DD`
A step may be required, may carry help, and may depend on an earlier answer
with when: { field, is } (is may be a list). A field the table requires must
be asked by a required step with no when, and the table's key must be asked
unless the store assigns it: required facts are always asked, never guessed.
Running one is stateless: send the answers so far and get the next question, or
complete with the row. MCP run_wizard {wizard, key, answers, save}; control
API POST ?action=data-wizard-run; in the manager, the wizard's button above the
rows (a new row) or Ask on a row (that row, each question showing what it
holds). A required question cannot be skipped; an optional one is skipped with
an empty answer. With save: true and every question answered, the row is
written through the ordinary row save, recorded as from wizard:<name>; it
needs what writing the row needs. list_wizards / data-wizards lists them.
A table can be removed, and until 0.10.27 it could not be -- declaring one was reachable from three surfaces and removing one from none, so a table made by mistake, or renamed, or created for a single test was permanent.
drop_data_tabledata-table-dropEvery drop, and every rebuild that loses a column, writes a safety export
first under lazysite/db/rebuilds/ -- the only copy of the rows it removed.
list_data_safety_exports (control API data-safety-exports) lists them with
their table, kind and stamp; delete_data_safety_export
(data-safety-export-delete) clears one by its exact file name, audited.
read_data_safety_export (data-safety-export-read) opens one -- table,
key, fields, rows -- and the listing carries each export's row count and a
key sample, so it can be judged first. restore_data_safety_export
(data-safety-export-restore) offers the rows back to their table: a plan
without apply, a write with it; columns the table no longer has are
reported, not refused -- re-declare them and restore again. Read an export,
or know it came from a throwaway, before clearing it.
It takes everything: the descriptor, the stored table and every row. So it asks first, and the confirmation is the table's own name rather than a yes:
drop_data_table { "table": "old_prices" }
-> needs_confirmation, naming what would be lost
drop_data_table { "table": "old_prices", "confirm": "old_prices" }
-> dropped
A safety export is written before anything is dropped, beside the rebuild
exports in lazysite/db/rebuilds/, and its path is returned. The table is not
recoverable; the data is.
If the export cannot be written, nothing is dropped.
textmax: limits its length; widget: textarea (or input) says
how it should be edited -- a 500-character description and a short title are
the same kind of value and differ only in how you type them.
integermin: and max: bound it.
decimaldigits: and places: -- a decimal without them is a float
wearing a name. Stored and returned as a string so it never passes through a
floating-point value: 120.00 comes back as 120.00.
boolean1/0, true/false, yes/no, and stores 0 or 1.
dateYYYY-MM-DD, checked against the calendar -- 2025-02-30 is refused.
datetimeYYYY-MM-DD HH:MM:SS, UTC, normalised to one spelling so string ordering
and chronology agree.
enumvalues:.
Every field may carry required: true and a default:.
The store checks rather than repairs. Each of these is refused with the field named:
An empty value means absence, not zero: clearing a number stores nothing
rather than 0, since 0 would be data the author did not enter. A text field
keeps its empty string, which is a real value.
Editing the descriptor and re-running the migration handles anything additive -- a new field, a new index, a default filled in on the rows that predate it.
Three changes are refused and reported instead: changing a field's type, tightening one to required, and removing one. All three rewrite the whole table, which is not something to do because a descriptor changed.
When you do want one, use rebuild_data_table (MCP) or data-rebuild (API).
It asks you to name each column whose data will be lost -- call it without
the list first to be told which those are. A list rather than a yes/no on
purpose: agreeing to lose one column you read about should not agree to a
second you did not notice. Every row is exported to lazysite/db/rebuilds/
before anything is dropped, and the path comes back with the result.
A backup carries table data. A site package carries it only when you ask, naming the tables:
{"host": "clients.example", "data_tables": ["products"]}
Opt-in because a package is a portable hand-over artefact: shipping table contents by default would mean handing a third party whatever is in a directory or a contact table. The tables are named rather than switched on, because the store is instance-wide -- there is no such thing as this domain's data, and a flag would sweep another site's tables into the package.
A package says what it left behind: data_omitted counts declared tables it
does not carry, so whoever receives it learns the tables exist. On apply, a
table that already holds rows is refused, not overwritten -- restoring over
a live product list would replace it with a snapshot from whenever the package
was built.
A restored row keeps what the package says about it:
key:) - rows that
name each other by it still do, and the next row added continues after the
highest;timestamps: true, its authors and its times:
created_by, updated_by, created_at and updated_at are what they were
when the package was built, and a row written anonymously stays anonymous;row_policy: true, its policy: a personal row is
restored personal, to the same owner, and only that owner may amend it.The account that applied the package is recorded in the log and the audit
trail, and data_restored says how many rows kept each (keys_kept,
authors_kept, times_kept, policies_kept). A package built before 0.15.5
carries no authors, times or policies: its rows are recorded as the applier's,
at the time of the restore, with no policy.
Reading and writing tables through the manager, the API or MCP needs the
manage_data capability, granted like any other through a group.
A page binding is not a capability question: it renders as part of the page, so a gated section's table is as reachable as the section is.
manage_data is an instance capability, and a table name is
instance-wide -- there is one lazysite/db/tables/ for the whole
instance, not one per domain. On a single-site instance that is all there
is to know.
Where one instance serves several domains belonging to different people, say which domain a table belongs to:
domain: shop.example.com
A caller whose grant confines it to one domain's content then reaches that domain's tables and no others -- and is not told that the rest exist, because a table name is itself a disclosure. That is why an unpublished table is invisible to a visitor in the first place.
A table that names no domain is reachable by any manage_data
holder on the instance. That is deliberate, so an upgrade takes nothing
away from a running application, and it means the protection is opt-in:
until you write domain: on a table, a partner scoped to a neighbouring
domain can read it.
Two things follow, and both matter on a shared instance:
domain: to every table on an instance that hosts unrelated
parties. lazysite-check lists the ones that lack it.The older mitigation still works and composes with this: ACL lookup takes
the longest matching prefix, so a restrictive rule on
lazysite/db/tables plus a per-table rule for each site's own people
closes the same gap by hand.
A gallery is an ordered list -- somebody chose the sequence. Say so once, on
the table, rather than repeating order= in every binding and getting it wrong
in one of them:
default_order: position # or -position, for descending
A binding that names its own order still wins; this fills the gap when one does not.
Leave key: out (or write key: id) and the table numbers its rows itself: an
insert carries no key, and the store gives it the next integer as id. That is
the shape for a table a form writes to - one row per enquiry - where the only
value a visitor brings that could identify a row is their email, and a table
keyed on that keeps only each person's latest. id is then reserved: no field
may be called that.
key: says which field identifies a row. To say that another field must also
be unique -- a slug, a reference, an email -- mark the field:
fields:
slug:
type: text
unique: true
Two rows cannot then share a value. Empty values are exempt: any number of rows may leave it blank, which is what "unique" means everywhere else and is worth saying because the opposite is a fair guess.
Adding unique: true to a field that already holds duplicates is reported,
not attempted -- the migration tells you which value appears twice, and you
make the existing rows distinct before migrating again.
A page's own JavaScript reads rows from /cgi-bin/lazysite-data.pl?table=<name>,
and that address is reached directly -- it inherits nothing from any page.
Putting a table behind a gated page gates the page, not the table.
The endpoint reads four query parameters and no others: order_by,
order, limit, offset. Anything else is refused - ?table=t&chunk=AAA
answers 400 with kind: unknown-parameter, naming chunk and the four it reads,
rather than every row (SM1377; before 0.16.6 it was ignored, SM606). The one
exception is _, the usual cache-buster, which is accepted and left unread.
Filter on the page binding (db:t(chunk=AAA)), which does support conditions,
or filter the rows in your own script after reading them.
So publication is declared on the table itself:
public: true
key: code
fields:
code:
type: text
public (like timestamps, required and unique) accepts true/false,
yes/no, on/off or 1/0 in any case; any other word is refused when
the descriptor loads, so a flag never silently reads as the opposite of what
you wrote.
The default is false, and a table is a store rather than a published
artefact -- a file is under the docroot because you put it there to be served,
and a table is not. Until you publish it, an anonymous visitor sees nothing:
not the rows, and not that the table exists at all. It answers exactly as a
table that was never declared does, so nobody can discover your table names by
guessing at them.
public is about anonymous visitors. Any signed-in account may read an
unpublished table unless an ACL says otherwise, which is the next section.
Publishing a table publishes every row in it. To keep some back - rows a moderator has marked hidden or spam - give the table a row condition:
public: true
where: status = ok
or, to show everything but the rows marked one way,
where: status != spam
The condition is applied wherever a visitor can see rows: page bindings
(db:<table>, .count, .field) and the data endpoint. It is added to any
filter the binding asks for, so a binding cannot reach the rows it hides, and a
count counts only what is shown. The manager, the API and MCP -- the surfaces
behind manage_data -- still see every row, which is how a moderator finds the
ones that are held back.
It is one comparison and nothing more: a declared field, = or !=, and a
value, read as that field's type would read it (an enum value must be one of
its values). Anything else -- another operator, AND/OR, a field the table
does not declare -- is refused when the descriptor loads. With !=, a row whose
field is empty is shown: it has not been marked.
Tables use the same read/write lists as files, in the same store, with the
same meaning: entries are a username or @group, and no entry means no
restriction. A table's ACL path is its descriptor's own path:
lazysite/db/tables/<name>
Because the matcher takes the longest matching prefix, a rule on
lazysite/db/tables governs every table at once, and a site-wide private
rule covers tables exactly as it covers pages.
A read list and public: true compose rather than compete: a published
table that also carries a read list still refuses an anonymous visitor, because
an anonymous visitor matches no entry in a list.
The lists bind a manage_data holder too (SM982). manage_data says an
account may work with tables; a table's own rule says who may read its rows and
who may change them, and the manager, the API, MCP and the data endpoint all
ask it: a read list the account is not in refuses its row reads and downloads,
and a write list it is not in refuses its row saves, deletes, imports, restores,
rebuilds and drops, with a reply naming the table's rule. Only the operator
-- the command line as local, an unsecured site, or a signed-in member of the
operator group -- is not asked; manage_users alone is asked like anyone. A page is never treated as an operator -- it
renders the same rows for whoever is looking at it, so what you see while signed
in is what your visitors see. The one exception is a binding that says (mine), which asks for the
viewer's own rows -- see A page that shows each viewer their own rows, below.
A visitor cannot write to a table directly, and that is deliberate: the data endpoint refuses an anonymous write. Use a form. A form has rate limits, spam assessment, quarantine and an audit trail, and a data binding taking anonymous writes would rebuild that surface without any of it.
Point a form at a table through a table handler - created on the Handlers
page, with save_handler / handler-save, or with lazysite-handlers.pl, by
an account holding manage_data (the destination decides who may
configure a handler; see Forms). It lands in
lazysite/forms/handlers.conf:
handlers:
- id: store-enquiries
type: table
name: Enquiries
table: enquiries
fields: name=name,email=email,message=body
keep_copy: false
Binding a form to it then needs only manage_forms. The schedule can call the
same handler with fixed fields, as the daemon's job account.
fields reads form field = column, and it is required. A form field nobody
maps is dropped, so a form gaining a field cannot start writing a column,
and a visitor cannot choose where their data goes by naming a field after one.
Values go through the same checks as any other write, so a submission that does not fit the declared types is refused rather than stored wrong -- the visitor is told the submission failed instead of being thanked for one that was quietly lost.
The data extension must be enabled; a form pointed at a table while it is switched off refuses and says so.
With keep_copy: true - the default - the handler also keeps the JSONL
submissions store written alongside the row -- the Submissions page, read_form_submissions, exports and bulk delete keep
working exactly as for a file handler. A submission the table's types refuse
leaves no row and the stored copy is marked _row_refused, the same shape
as a rejected import row. Reading the table's rows is governed by the
table's own declaration, like any other table: manage_data on the operator
surfaces, page bindings only what the descriptor declares readable, and
public defaults to closed -- a form never publishes its submissions by
landing them in a table. The JSONL copy stays under read_submissions.
files_column: names a column of the table to receive the submission's
uploaded files, as site-relative paths, comma separated -- one path per
file, in the order the browser sent them. Without it the files are reachable
only from the submissions record, so a lead worked from the table has no
photograph. A submission with no attachment leaves the column empty rather
than writing an empty string. It needs keep_copy on, because the copy is
what stores the files, and the column name is checked against the table's own
descriptor when the handler is saved rather than at a visitor's submission.
A file that could not be written is named on the stored record and in the
delivery's audit line: the submission still succeeds, and does not read as a
visitor who attached nothing.
write_data is the grant for an app's own users -- a learner writing their own
submissions, a member updating their own record. It is a grant over a table,
so without the flag below, ten applicants holding it on one applications table
may each amend and delete the other nine's rows.
row_policy: true gives every row in the table its own disposition:
row_policy |
Who may write the row |
|---|---|
personal |
the account in created_by, and an operator |
shared |
anyone the table already admits |
| empty | the same as shared |
The policy is set by whatever creates the row, at the moment of writing --
pass row_policy in the row like any other value. Nothing infers it: an empty
created_by already means "written anonymously by a public form", and reading
shared out of that absence would give one representation two meanings.
lazysite-check reports, per table, how many rows carry no policy, because
confinement protects nothing that already exists.created_by belongs to nobody, and nobody
can amend it but an operator. That is deliberate: it is what a row written by
a public form is, and what rows written before 0.14.x are.row_policy on a row you created is part of writing it; setting it on
anyone else's is refused whatever that row's current policy, so a shared row
cannot be captured. Taking someone else's row private needs manage_data.row_policy needs timestamps: true, refused by name when the descriptor
loads: a personal row is owned by the account in created_by, and an
ownership test with no owner to compare admits everybody.A form handler says what its rows are. A table handler takes
row_policy: personal, and each row it writes then belongs to the account that
submitted it -- so one applicant maintains their own application and nobody
else's. Leave it out and its rows are shared, which is how a handler behaved
before this existed, so nothing changes for a handler that says nothing.
personal needs the table to declare row_policy: true (refused when it does
not) and it needs the submitter to be signed in: a public form records no
account, so its rows would belong to nobody and only an operator could amend
them. The row is still stored in that case -- losing a submission to a
misconfiguration the visitor cannot see would be worse -- and the delivery's
audit line says what happened.
Reads are not confined by this -- row_policy decides who may write a row.
To show each person their own rows, bind with (mine), which is the next
section.
tt_page_var:
applications: db:applications(mine,order=-created_at)
mine_open: db:applications.count(mine,status=open)
mine adds created_by = <the signed-in viewer> to the binding, and the
engine writes that value -- the page never does, and nothing a visitor sends can
change it. The viewer is the account the sign-in verified (or the one an auth
proxy names, where the operator has set auth_proxy_trusted: true); an identity
header a visitor sends is discarded before the page renders. mine combines with the
other filters (AND), with order=, limit= and offset=, and with .count()
and .field(). It is the bare word: mine=x is an ordinary filter on a field
called mine, and a literal created_by= beside mine is refused.
auth: required) if a signed-out visitor should be sent to sign in
rather than shown an empty list.timestamps: true, because that is what creates
created_by. (mine) on a table without it is refused by name: the binding
renders nothing and the render log says "(mine) binding refused - the table
has no created_by", naming the table. validate_page reports it too, so it
can be caught before publishing. It never falls back to every row.created_by, and an
empty owner matches no viewer -- signed in or not. Nothing assigns them an
owner after the fact.The page is not cached. One viewer's rows must not be served to the next,
so a page whose front matter binds (mine) is treated exactly as a gated page
is: never read from or written to the page cache, and sent no-store. It is
rendered on every request, ttl: included. A (mine) binding anywhere else
-- a site-wide variable, an error page -- is refused and logged, because that
render could be shared.
A viewer's own rows, even of a table they may not read. A (mine)
binding in a page shows a signed-in viewer the rows whose created_by is
theirs even when the table's read rule (public:, its access list) excludes
them - so a member can see their own entries in a table no member may list.
Only their rows: the count, the total and a row by its key are all counted
over their own rows. Anything else on such a table still follows the rule:
a binding without (mine) renders nothing to them, an anonymous viewer gets
nothing, and the data endpoint, the manager, the API and MCP answer as
before.
A write through the manager, the API, MCP or the data endpoint needs all three:
manage_data capability;writable_by, if it names any;manage_data holder as for anyone but the operator.Every row write is recorded in the audit trail with the row's key, so an auditor can tell which row changed rather than only that the table did. On a table with thousands of rows that is thousands of lines.
audit_rows: off in the descriptor turns the per-row lines off for that table
only. Nothing else turns them off: an absent key, a typo, or any value other
than off / false / 0 leaves the table audited, because a misspelling must
not quietly stop recording who changed what.
key: code
audit_rows: off
fields:
code:
type: text
The table-level events are unaffected. Declaring, altering, migrating, dropping and importing each remain one audited event whatever this says - so quietening a noisy table never silences the record that it was restructured, emptied or bulk-loaded.
Before reaching for it: a bulk load belongs in data-import, which is already
one event for the whole file. The volume this addresses is an application
writing rows one at a time, where each really is a separate act.
writable_by only ever takes access away. It cannot grant a write to an
account without manage_data, because the descriptor is a file an agent can
write -- if the list could widen, an agent could hand write access to a group
it chose.
writable_by means two different things, depending on which capability the
caller holds.
For manage_data it only ever takes access away: a holder writes every
table, except those naming groups it is not in.
For write_data it is an allow-list: a holder writes only the tables
that name one of its groups, and a table naming nobody is closed to it entirely.
| Caller holds | no writable_by |
writable_by: [secretaries] |
|---|---|---|
manage_data |
writes | writes only if in secretaries |
write_data |
refused | writes only if in secretaries |
That asymmetry is what keeps the descriptor from being a grant. The file is
writable by any agent holding manage_data, so if the list could hand write
access to an account that had none, an agent could give it to a group it chose.
It cannot: write_data comes from the group store, where an operator granted
it, and the list only says which tables that grant reaches.
write_data is the grant for an app's own users -- a learner writing their own
submissions, a member updating their own record. It permits row insert, update
and delete and nothing else: no declaring, altering, migrating or dropping a
table, and no reach into a table that does not name them. Where manage_data
would mean handing instance-wide data administration to your least-trusted user
class, this is what to grant instead.
key: code
writable_by:
- secretaries
fields:
code:
type: text
Leave it out and any manage_data holder may write.
An import is a row write (SM1052). data-import follows the same rule: on a
table with writable_by, an account the list names imports, and one it does
not name is refused. To bulk-load such a table, add your group to
writable_by - never clear the list for the load and restore it after. The
table's owner may also import past the list: the owner of its access rule
(the account that first set it with data-table-acl-set, or the one the
operator named). A table with no access rule has no owner.
writable= on a page binding is not a permission. It tells the page's own
script whether to offer editing controls. The endpoint cannot see which page
called it, so a marker in front matter could never gate anything.
/cgi-bin/lazysite-data.pl -- reached directly, so it is available to a page's
JavaScript wherever that page lives.
columns: Call | Answers
widths: 8cm | X
bold: 1
tone: medium
---
`GET ?table=notes` | `{ok, table, rows}`
`GET ?table=notes&order_by=code&order=desc&limit=20&offset=40` | the same, ordered and paged
`GET ?csrf=1` | `{ok, token}` for a signed-in caller, and nothing for anyone else
`POST ?table=notes` with `X-CSRF-Token` | `{"row": {...}}` to insert, `{"key": "...", "row": {...}}` to update
`POST ?table=notes` with `{"key": "...", "delete": 1}` | removes that row
A write that is refused says which of the three gates stopped it -- kind is
anonymous, csrf or forbidden -- so a page can tell "sign in" from "your
token went stale" from "this is not yours to edit" and say the right thing.
Fetch the token once per page and reuse it; fetch a fresh one and retry if a
write comes back csrf.
You do not have to write the fetching. Declare a region and what one row looks like, and the shipped helper does the rest:
<ul data-ls-db="products" data-ls-db-order="-price" data-ls-db-limit="10">
<template>
<li><span data-ls-field="name"></span> —
<em data-ls-field="price"></em></li>
</template>
<p data-ls-empty>Nothing here yet.</p>
</ul>
columns: Attribute | Means
widths: 6.4cm | X
bold: 1
tone: medium
---
`data-ls-db` | the table to read
`data-ls-db-order` | a field, `-field` for descending
`data-ls-db-limit` / `-offset` | how many, from where
`data-ls-db-every` | refresh every N seconds -- **opt in**, minimum 5
`data-ls-field` | on any element inside the template: put this column's value here
`data-ls-empty` | shown when there are no rows, hidden when there are
The script is added to a page only when that page has a region, so a site that never uses one ships nothing extra.
Values are inserted as text, never as markup. A row containing <script>
renders as those characters. This matters because rows can arrive from a public
form, and a helper that treated them as markup would turn a contact form into a
way to run script on every visitor's page.
A refresh that fails leaves what is already on the page. Content a minute old beats an empty list because one request timed out.
Regions load when they come into view, so a table below the fold on a page
nobody scrolls costs nothing. Nothing polls unless you set data-ls-db-every.
The helper reads through the same endpoint and the same rules as anything else: an unpublished table returns nothing to it, exactly as it does to anyone.
A table may declare shows_from and shows_until (either or both, each
a date or a datetime). A row outside its window is not shown - by a page
binding, its _total and .count(), and the data endpoint - with no update
needed when the date comes:
fields:
title:
type: text
shows_from:
type: date
shows_until:
type: date
time_zone: in
lazysite.conf): the row shows from the start of shows_from and through the
end of shows_until.A page showing such a table expires when the next row enters or leaves its
window, so the offer that ends tonight is gone from the cached page at
midnight. The manager's data page, the control API and MCP - the manage_data
surfaces - list every row, so an editor finds the one that has not started yet.
A schedule is the site's opening hours: weekly periods (several a day if
it closes for lunch) and exceptions (a date or a date range, closed or with
other hours). It is a table whose descriptor says schedule: true - the
engine gives it its fields, so every schedule reads the same way:
title: Opening hours
schedule: true
public: true
columns: Field | Holds
widths: 3.2cm | X
bold: 1
tone: medium
---
`kind` | `period` or `exception`
`day` | a period's day: `mon` ... `sun`
`opens`, `closes` | `HH:MM`, 00:00 to 24:00; a period closing before it opens runs past midnight (`18:00` to `01:00`)
`date_from`, `date_until` | an exception's date, or its range (`date_until` empty for one day)
`closed` | an exception that closes the day; otherwise its `opens` and `closes` replace the week's hours for those days
`note` | an exception's note ("Stocktake")
An owner adds a period or an exception one question at a time with this wizard
(see "Wizards" above), saved as opening_hours over the table - here opening
- with MCP save_wizard or control API data-wizard-save:
title: Add opening hours or an exception
table: opening
steps:
- field: kind
ask: Is this the weekly hours for a day, or an exception for a date?
type: choice
required: true
help: period is a day of the usual week; exception is a date or dates that differ.
- field: day
ask: Which day of the week?
type: choice
required: true
when: { field: kind, is: period }
- field: date_from
ask: On what date does it start?
type: date
required: true
when: { field: kind, is: exception }
- field: date_until
ask: Until what date?
type: date
help: Leave it empty for a single day.
when: { field: kind, is: exception }
- field: closed
ask: Is it closed all day?
type: yesno
when: { field: kind, is: exception }
- field: opens
ask: When does it open?
type: text
help: HH:MM, such as 09:00. Leave it empty for a day it is closed.
- field: closes
ask: When does it close?
type: text
help: HH:MM, such as 17:30; 24:00 is midnight. Leave it empty for a day it is closed.
- field: note
ask: A note to show with it?
type: text
help: Such as Stocktake. Leave it empty for none.
when: { field: kind, is: exception }
The times are asked of every row and may be left empty: a period without them, or an exception neither closed nor with hours, is refused when the row is saved, as below, and nothing is written.
The rows are written through the doors every table has - the manager's data
page, save_data_row (MCP), data-row-save (control API), the data endpoint -
and a row the schedule could not read (a time that is not HH:MM, a period with
no day, an exception neither closed nor with hours) is refused at the write, by
name. The descriptor may declare fields of its own beside these; it may not
redeclare them. Times are in the site's zone (time_zone:), summer time
included: the zone's own rules decide when 09:00 is.
A page shows it with schedule::
tt_page_var:
hours: schedule:opening
and the layout has these blocks to render:
columns: Variable | Is
widths: 5.2cm | X
bold: 1
tone: medium
---
`hours.label` | "Open now until 17:00", "Opens at 12:00", "Opens tomorrow at 09:00", "Opens Monday at 09:00", or "Closed"
`hours.open`, `hours.until` | 1 when open now, and until when (`HH:MM`)
`hours.next_open` | `date`, `day`, `time` and `when` (`today`, `tomorrow`, `later`) of the next opening
`hours.week` | seven days from Monday: `name`, `periods` (each `opens`, `closes`), `closed`, `today`
`hours.today` | today's `periods` with any exception applied, `closed`, `exception`
`hours.next_exception` | the next exception still in force: `label` ("Closed Friday 25 December"), `date_from`, `date_until`, `closed`, `opens`, `closes`, `note`
`hours.exceptions` | every exception still to come, in date order, the same shape
<p class="status">[% hours.label %]</p>
<table>[% FOREACH d IN hours.week %]
<tr[% IF d.today %] class="today"[% END %]><th>[% d.name %]</th>
<td>[% IF d.closed %]Closed[% ELSE %][% FOREACH p IN d.periods %][% p.opens %]-[% p.closes %] [% END %][% END %]</td></tr>
[% END %]</table>
[% IF hours.next_exception %]<p>[% hours.next_exception.label %][% IF hours.next_exception.note %] - [% hours.next_exception.note %][% END %]</p>[% END %]
Every value is escaped as a db: value is. A page showing "open now" is
cached only until the next change of state - the next opening or closing,
or the next local midnight, whichever is sooner - and a row changed refreshes it
at once: it never serves a stale "open now", and it does not re-render in
between. Its ttl: and the response's max-age never reach past that moment.
A schedule: line in lazysite.conf puts the same blocks on every page for a
layout to use, at the cost of a read of the table on every request; on the page
that shows the hours it costs nothing between changes.
An exception is removed once its date has passed (the release manager's
ruling). The daemon's schedule-exceptions job does it hourly, under the job
account's manage_data, and writes each removal to the audit trail with the
exception it removed (action schedule-exception-removed, the row as JSON
in the line's detail), so the history reads back. With the audit trail off
(audit_trail: off) nothing is removed: the removal is kept only where the
trail can hold it. A passed exception changes nothing a visitor sees either way
- the schedule reads from today on.
A table can be published to search engines and AI search as schema.org
JSON-LD: its descriptor says what the rows are and which pages carry them,
under structured_data:, and the engine adds the JSON-LD to those pages
from the rows an anonymous visitor may read now. The engine ships no mapping for
any market; an app ships the mapping for its own with its tables.
title: Events
public: true
fields:
title: { type: text }
starts: { type: date }
venue: { type: text }
structured_data:
type: Event # a schema.org type
pages: [/events] # the pages that carry it
properties:
name: title # property: a field of the table
startDate: starts
eventStatus: "=https://schema.org/EventScheduled" # =text is literal
location: # a nested object
type: Place
properties:
name: venue
The mapping is checked when the descriptor is saved, and refused by name: a
type or property that is not a schema.org name, a field the table does not
have, a page that is not a site path. On a schedule: true table it describes
one place - its properties are literals - and the engine adds
openingHoursSpecification from the schedule.
What is published is what the site already serves publicly. The rows are
read as an anonymous visitor reads them: a table that is not public: true
publishes nothing, a where: row condition and shows_from/shows_until
apply, and a link property (url, image, logo ...) whose value is not a web
or mail address is left out. Every value is encoded so it cannot leave the
<script> element that holds it. A row changed refreshes the pages carrying it,
as for any page that shows a table.
The organisation entry is the site's own: its name and address from
lazysite.conf (site_name, site_url, and site_type, site_address,
site_phone, site_email, site_hours naming a schedule), on the home page,
published once one of the details is set. See
Configuration.
The sitemap and the AI-readable summary list every public page. A page with
no register: key is in sitemap.xml and llms.txt; a page with one keeps
what it says. The two list the same pages, from one page set: a page registered
for either is in both. Left out whatever they register: a protected or draft page, a page
copy, an api: or raw: page, a page that says search: false, a redirect,
a starter demonstration page and the engine's sign-in and error pages, and a
folder that looks like another site's content (under sites/, or with its own
lazysite.conf) and is not a declared content root. The
summary (llms.txt) carries each page's title and description and the
organisation entry; llms_txt: off turns it off.
lazysite check names a public page with no title or no description, a
title two pages share, and every finding in the JSON-LD the mappings emit - a
row missing what its type needs (an Event's name and startDate), a date that
is not ISO 8601, a page the mapping names that the site does not have, a table
no visitor may read. A type it does not know is emitted as mapped and said to
be unchecked.
Where another system already holds a fact - a booking platform's bookings, a
directory's listings - a table can read it from there instead of keeping a
second copy someone enters twice. Its descriptor names a source:: a GET
connector, the schedule entry that refreshes it, and which field of each item
fills which column.
title: Bookings
key: ref # the source's own identifier, never the automatic id
fields:
ref: { type: text, required: true }
guest: { type: text }
seats: { type: integer }
note: { type: text } # not mapped: the site's own column
source:
connector: bookings # a connector with method GET
schedule: bookings-refresh # the schedule.conf entry that refreshes it
rows: data.items # where the list is in the answer (absent: the answer itself)
map: # column: field of each item (a dotted path reaches inside)
ref: id
guest: guest.name
seats: seats
The parts are the live-data chain's.
The operator saves the connector on the Connectors page with method: GET,
modes.scheduled on; it needs no answer_table - the descriptor's
source.connector is the one link, and every table that names the connector
is refreshed by its answer (a connector that already names this table as its
answer_table works the same). Its URL, its
credential (the connector's secret), the egress bounds and the call record are
the connector's, and nothing in the descriptor reaches the network. A
connector handler names the connector, and the schedule entry the descriptor
names calls the handler with its fixed fields:
schedule:
- id: bookings-refresh
handler: bookings-pull
every: 900
payload: {"venue":"main"}
A sourced table's answer is not appended as a call row: each item becomes a row, by map. A row the source sends is updated -
its mapped columns only, so the site's own columns keep their values - and an
item the table has not seen is inserted. A row the source stops sending is
removed, as a recorded change in the row history (list_row_changes, from
refresh:<table>), so undo_row_change puts it back - until a later refresh
that still does not send it removes it again. Only an answer that is a list
with at least one item removes anything: an empty list, or a refresh that
fails, leaves every row where it is.
Refresh now with refresh_data_table (MCP) or data-table-refresh (API),
both needing manage_data, or Refresh now on the table's rows in the
manager's Data page, which also shows how old the rows are and how the last
refresh went. It is the schedule entry's call, made as you: the
connector's callers groups (or manage_connectors) decide whether you may
cause it, and it is recorded against the entry like a scheduled run.
A failed refresh changes nothing. The remote down, an answer that is not
JSON, a list that is not where rows says, an item without the key, a value a
column refuses - each leaves the table exactly as it was, so its rows are the
last good copy. The failure is in the connector's call record, the event log and
the schedule's run record, and the schedule lists the entry as failed with the
reason.
How old the rows are is in describe_data_table / data-table, under
source: refreshed_at and age_seconds count from the last refresh that
delivered, which a failure does not move; last_attempt, outcome and
reason say what happened since. They are read from the schedule's run record
(lazysite/daemon/schedule-runs.json).
Mapped columns are read-only. source.read_only lists them. A write to one
is refused on every door - the manager, the API, MCP, the data endpoint, a
wizard, a switch and a CSV import - naming the column and the source, because a
local edit would be undone by the next refresh: change it at the source. A new
row is refused too, since its key is the source's. An unmapped column is written
as on any table.
The descriptor is refused when saved for a key that is the automatic id, a
map that does not fill the key, a column it does not declare, or a missing
schedule.
lazysite/db/tables/<name>.yamllazysite/db/data.sqlitelazysite/db/rebuilds/lazysite/db/stamps/<table>