# Deployment ## Single host: embedded The default. Each account directory carries its own local database — `mvxdata.sqlite`, or `mvxdata.lmdb` for an `lmdb` account; concurrent sessions on one host share it safely. Directory files are plain directories. Nothing to operate. A local database can live outside the account, which is what a container needs when the image is read-only and the data belongs on a mounted volume. That is a **connection profile** — see *Named connections* below. ## More than one host An embedded database must never live on a network filesystem, so an account reached from several hosts puts its files on a server built for it: **`postgres`**, or `mysql` or `mongo`. ``` > SET-CONNECTION salesdb driver=postgres address=mvxdb:5432 dbname=mvx user=app > CREATE-FILE ORDERS USING @salesdb ``` The committed `BINDINGS` record names only the profile (`ORDERS @salesdb`), so it carries no address and no password; the profile is local and private. Moving an existing file is `CONVERT-FILE` — records, dictionary and all — and a whole account is `CONVERT-ACCOUNT`: ``` > CONVERT-FILE ORDERS postgres > CONVERT-ACCOUNT postgres ``` > **There used to be a daemon of our own.** `mvx-lmdbd`, reached through an > `lmdbnet` driver, served LMDB over a socket. Both were removed (mvx#327) > after a deprecation in v0.6.0. The reason was pushdown: `lmdbnet` supplied > none of `select_where`, `select_order`, `count_where`, `sum_where`, join or > explain, so every `WITH` streamed the whole id list to the verb and filtered > it there — and unlike embedded `lmdb` it paid network latency to do it. > `postgres` answers those in SQL, holds record locks across sessions, and is > not ours to maintain. > > If you have an account on it, the last release that can read it is v0.6.0. > Move the files from there with `CONVERT-ACCOUNT postgres CONNECTION @salesdb` > before upgrading. `$MVXDAEMON` is no longer read: an account that still sets > it is simply unbound and takes the account's default. ## Choosing a backend per file A file's backend is named at creation: ``` > CREATE-FILE ORDERS the account's default (sqlite) > CREATE-FILE ARCHIVE DIR directory (git-native source) > CREATE-FILE SHARED USING postgres a shared SQL database > CREATE-FILE LOCAL USING lmdb a local key/value store > CREATE-FILE ORDERS USING @salesdb a named connection (below) ``` `USING {connection}` binds the file to a storage driver — `sqlite`, `lmdb`, `dir`, `postgres`, `mysql` or `mongo` — recording it in the account's `BINDINGS` record and creating the file through that driver. `DELETE-FILE` removes the file and its binding; `LISTF` shows each file's backend by name. Different files may use different drivers and connections, so one account can mix a local database, files on several servers and directory files — and one program reads them all through ordinary `OPEN`/`READ`/`WRITE`. The binding is a plain, hand-editable record: `SPEC driver {params}` per line (`*` as the spec binds every file; `params` is the driver's connection string, or `@name` for a profile). A line naming a file and **no driver** means the account's default. Binding is resolution only; existing data does not move when it changes. To actually move a file to a different backend, use **`CONVERT-FILE`**: ``` > CONVERT-FILE ORDERS dir hash file -> directory file > CONVERT-FILE ORDERS lmdb directory file -> hash file > CONVERT-FILE ORDERS postgres local -> shared SQL ``` `CONVERT-FILE file newtype {connection}` re-keys the file's records — and its dictionary — into the new backend verbatim, so a hash file round-trips to a directory file and back without loss, and the `%FILE%` control record is restamped to the new type. ### A whole account at once: `CONVERT-ACCOUNT` Moving every file one `CONVERT-FILE` at a time invites missing one, which is a poor way to retire a backend. `CONVERT-ACCOUNT` does the account: ``` > CONVERT-ACCOUNT sqlite everything not already there > CONVERT-ACCOUNT postgres FROM lmdb only what is on one backend > CONVERT-ACCOUNT postgres CONNECTION @sales with a connection profile > CONVERT-ACCOUNT sqlite LISTONLY print the plan, change nothing ``` `FROM` is the retirement case — *move what is on the backend being retired, leave the rest alone*. `LISTONLY` prints the plan and touches nothing, which is how to find out what a conversion would do rather than discovering it afterwards. Three rules are worth knowing before you run it: - **A directory file is skipped** unless you write `FROM dir`. The point of a `dir` file is that its records are OS files a person can read and `git` can diff; converting one to a hash backend destroys exactly the property it was created for. So the obvious command leaves your `BP` alone. - **`CATALOG` is never converted**, not even by `FROM dir`. The shell reaches a cataloged verb by path — a VOC record says `CATALOG/CREATE-FILE` and the shell execs it — so converting it would stop every verb in the account from running. - **`VOC` goes last.** Resolution starts there, so it is the one file whose move changes where everything else is looked up. There is **no rollback across an account**. Each `CONVERT-FILE` is individually atomic — it stages into a temp file, then replaces — so a failure part-way leaves the earlier files converted and the later ones untouched, and the report names what did not make it. If the *first* file fails, nothing has been converted and the run stops there rather than repeating the same error for every file: a wrong target fails the same way for all of them. What the daemon guarantees: - **Single lock authority.** `READU` locks are granted by the daemon and **leased to the connection**: a client that dies without `RELEASE` loses its locks the moment the connection drops. No orphaned locks from killed pods. - **Index atomicity.** A record write and its index updates commit in one daemon-side transaction. - **Snapshot selects.** `SELECT` materialises the id list before sending; a slow client never pins a read transaction. Costs, accepted deliberately: the daemon is a single point of failure, the single-writer throughput ceiling remains, and replication/HA is not provided. Protocol integers are host-order — keep daemon and clients on the same architecture for now. ## Several accounts on one server, isolated by namespace A profile's **`namespace`** is what keeps two accounts on one database apart. Each backend spends it as its own native partition: postgres as the **schema**, mongo as the **database**. Same-named files in different namespaces are fully isolated — two accounts can each have their own private `ORDERS`. - **It defaults to the account's own name** (the basename of the account directory), so pointing two accounts at one server isolates them with no configuration at all. - **Sharing is explicit — the Q-pointer analog.** Name the home namespace in the profile, and any account that names `SALES` reads `SALES`'s data: ``` > SET-CONNECTION sales driver=postgres address=mvxdb:5432 dbname=mvx namespace=SALES > CREATE-FILE ORDERS USING @sales ``` - **Dictionaries are per-account views.** A shared data file's dictionary (and its `%FILE%`/`%INDEXES%` control records) lives with the account that binds it, so different accounts can hold different column definitions over the same shared data. Index *data* is shared; index *definitions* are local. - **Locking is the server's.** A `READU` on a postgres file takes a session-level advisory lock, so it is honoured across processes and released when the session ends. - **No cross-server atomicity.** An account can bind files on several servers, but a unit of work spanning two of them cannot be one transaction. Authentication is the server's too: a postgres role and password, a mongo user. The password belongs in the credential store or the environment, never in `BINDINGS` — see *Credentials and secrets* below. ### Named connections Rather than write the daemon host into every file's binding, define a **named connection** once and reference it. The committed `BINDINGS` names only the connection; the deployment-specific host, namespace, and token live in the local (git-ignored) `.mvx-private/connections`: ``` > SET-CONNECTION salesdb driver=postgres address=mvxdb:5432 dbname=mvx user=app password= namespace=SALES > CREATE-FILE ORDERS USING @salesdb > CREATE-FILE CUSTOMERS USING @salesdb ``` ``` BINDINGS: ORDERS @salesdb CUSTOMERS @salesdb ``` Move the database to a new host and you change **one** field — `SET-CONNECTION salesdb address=newhost:5432` — with no change to `BINDINGS` and nothing to re-commit. The same profile carries the password, so a connection is the single place a file's whole remote binding lives; `LIST-CONNECTIONS` shows the profiles with secret fields masked. It is also how a container injects a target — set `MVXCONN_SALESDB_ADDRESS` and `MVXCONN_SALESDB_PASSWORD` and no file is needed. The local backends read a profile too (mvx#326), which is how a `sqlite` or `lmdb` database lives outside the account — on a mounted volume, where a read-only image needs it. The inline form (`CREATE-FILE … USING postgres `) still works for a one-off. ## Postgres A file can live on a PostgreSQL database instead of LMDB. Each account/namespace is a **schema**; each file is a **table** `(id text, doc jsonb)` — the record is a JSON document, one field per attribute, and the id is text you can read. Records round-trip byte-exact, value marks and all. Define a `postgres` connection and bind through it: ``` > SET-CONNECTION pgmain driver=postgres address=db:5432 dbname=mvx user=app password= namespace=sales > CREATE-FILE ORDERS USING @pgmain > LIST ORDERS NAME PRICE ``` `LIST`, `SELECT`, `SORT`, and `COUNT` work as on any file. The driver is built only where **libpq** is present (it is skipped otherwise), and it authenticates with the credentials in the connection profile (`.mvx-private`), never in committed config. The Postgres backend goes well beyond the minimal contract: mapped files push `WITH`/`BY`/`FIRST` queries down to SQL (see the mapping chapter), `CREATE-INDEX` builds native indexes, `LISTF` on an account bound entirely to Postgres (`* @conn`) enumerates the schema's record tables (`DICT.` and association child tables excluded), and **record locks are arbitrated across processes** — a `READU` takes a Postgres session-level *advisory* lock keyed by the file and record id, so a `READU` from one process blocks (or, with `LOCKED`, reports busy to) another process on the same PG-backed file, and the lock releases on `WRITE` / `RELEASE` or when the holding session ends. No extra tables or configuration: the advisory locks live in Postgres itself. The advisory lock is taken for native-mapped and unmapped files (where the backend holds the authoritative data); a mirror-mapped file, whose SQL is only a derived projection of an authoritative record blob, uses the process-local lock table instead. ## MySQL and MariaDB A file can live on MySQL or MariaDB through the `mysql` driver — one table per file, `(id, doc)`, the record as a JSON document. Both servers are supported and the driver avoids MySQL-only syntax where it can: attribute expressions use `JSON_UNQUOTE(JSON_EXTRACT(...))` rather than the `->>` shorthand, which MariaDB does not accept. ``` > SET-CONNECTION mydb driver=mysql address=db:3306 dbname=mvx user=app password= > CREATE-FILE ORDERS USING @mydb ``` **One difference between the two, and it is about indexes.** Indexing an **un-mapped** attribute needs a *multi-valued* index — MySQL 8.0.17 and later — because the push-down asks whether any one of the attribute's values matches, and a multi-valued index is the only kind that question can use. MariaDB does not implement multi-valued indexes at all, so there: ``` > CREATE-INDEX ORDERS CITY ORDERS.CITY cannot be indexed by this backend the query still works -- the filter runs in the backend or the verb, unindexed, so only speed is lost ``` Nothing silently changes meaning: the `WITH` is still pushed into MariaDB as `JSON_CONTAINS(JSON_EXTRACT(...))` and still answers correctly, including for multivalued attributes. Only the index is absent, so the server scans. **The workaround is to map the field first**, which gives it a real column that both servers index normally: ``` > CREATE-MAP ORDERS CITY > CREATE-INDEX ORDERS CITY index ORDERS.CITY built, 2 record(s) ``` | | MySQL 8 | MariaDB | |---|---|---| | mapped column | indexed | indexed | | un-mapped attribute | indexed (multi-valued) | refused; pushed down and scanned | The driver is built only where a MySQL/MariaDB client library is present and is skipped otherwise. ## MongoDB A file can also live on MongoDB. Each account/namespace is a **database**; each file is a **collection**; and each record is a document `{ _id: "", doc: { … } }` — a string id you can read, and the record as a BSON sub-document. Records round-trip byte-exact, value marks and all. Define a `mongo` connection and bind through it: ``` > SET-CONNECTION mongomain driver=mongo address=db:27017 namespace=sales user=app password= > CREATE-FILE ORDERS USING @mongomain > LIST ORDERS NAME PRICE ``` `address` is `host:port` (or set a full `mongodb://…` URI as the connection instead of `@name`); credentials come from the connection profile (`.mvx-private`), never committed config. The driver is built only where **libmongoc** (the mongo-c-driver, v1 or v2) is present, and is skipped otherwise. ### Relational mapping, indexes, and push-down `CREATE-MAP` (see [Relational mapping](files.md#relational-mapping)) works against Mongo in **mirror mode**: the record blob stays authoritative, and each mapped dictionary column is projected onto the document as a **native BSON field** — scalar columns as top-level fields, and an association as an **embedded array of sub-documents** (the natural Mongo shape). Given a `MORD` file mapped on `NAME`, `STATE`, `PRICE`, and an association `LINES` (`PRODUCT`, `QTY`), a record stores as: ``` { _id: "O1", rec: , NAME: "Widget", STATE: "NSW", PRICE: 9.99, LINES: [ { PRODUCT: "A", QTY: 2 }, { PRODUCT: "B", QTY: 3 } ] } ``` NUMERIC columns store as BSON numbers (so they sort, sum, and display as numbers). With the projection in place: - an equality **`WITH`** filter (`= ` / `#`) on a mapped column and a filtered **`COUNT`** run **server-side** (`$in`/`$nin`, so the record need not stream to the verb); - **`CREATE-INDEX`** on a mapped column builds a real Mongo index (`createIndexes`), and indexed lookups read from it. The `rec` blob is updated in place (`$set`) rather than replaced, so mapped fields survive a write that did not change them. Still evaluated in the verb by scanning (client-side): native read-back (mirror mode only), `SUM` / `BY` / multi-condition push-down, raw-attribute filters (Mongo cannot split the blob server-side), `TRANS()` joins, `DESCRIBE`, and cross-process locks — see #62. ## Credentials and secrets (`.mvx-private`) Backends that authenticate — a networked-LMDB namespace token, a Postgres username and password — need a secret to connect. Those secrets must **not** live in git-committed account config (`BINDINGS`, `.mvx`, the VOC): a clone or `BUILD` should provision an account without carrying its credentials, with the operator supplying them out-of-band. So secrets live in a per-account directory **`.mvx-private/`**, a single `credentials` file, netrc/pgpass-style — created `0700`/`0600`, and git-ignored. `BINDINGS` names only the non-secret reference (driver, target, key); the runtime resolves the secret from the store. ``` > SET-CREDENTIAL mongo mvxdb-a:27017 SALES token=abc123 > SET-CREDENTIAL postgres db:5432 mvx user=app password=s3cret > LIST-CREDENTIALS values are masked ``` `.mvx-private/credentials` then holds one **field per line** — the first three whitespace tokens are `driver target key`, and the rest of the line is `field=value`, where the value runs to end-of-line and may contain spaces and other characters (so an arbitrary secret survives verbatim): ``` mongo mvxdb-a:27017 SALES token=abc123 postgres db:5432 mvx user=app postgres db:5432 mvx password=p@ss w0rd :/@ ``` An environment variable overrides the file so a container can inject a secret without writing one — `MVXCRED___`, upper-cased with non-alphanumerics as `_` (e.g. `MVXCRED_LMDBNET_SALES_TOKEN`). Resolution is env override, then the file, then a clear error. ### Deploying secrets from CI / GitHub Actions The store is plain text with an end-of-line value, so a deploy job can pull secrets from GitHub Actions secrets and write them out. Two patterns: **Whole file as one secret** — keep the entire `credentials` file content in a single repository/environment secret and write it with a tight umask: ```yaml - name: Write credentials run: | mkdir -p account/.mvx-private ( umask 077; printf '%s' "${{ secrets.MVX_CREDENTIALS }}" \ > account/.mvx-private/credentials ) ``` **Per-field env vars** — map individual secrets to the override variables and write no file at all: ```yaml env: MVXCRED_POSTGRES_MVX_PASSWORD: ${{ secrets.PG_PASSWORD }} MVXCRED_LMDBNET_SALES_TOKEN: ${{ secrets.SALES_TOKEN }} ``` Either way the secret never lands in git; `.mvx-private/` stays local to the deployed account. Keep `.mvx-private/` out of version control — it is a dotfile, so MVX's own account export already skips it; for an account kept in a plain git repo, add it to `.gitignore` alongside `mvxdata.lmdb/`. The consuming backends (networked-LMDB tokens, the Postgres driver) are tracked in [#7](https://github.com/mvx-lang/mvx/issues/7), [#9](https://github.com/mvx-lang/mvx/issues/9), and [#11](https://github.com/mvx-lang/mvx/issues/11). ## Committing and cloning an account through git Hash files are binary, so an account is never committed as-is. Git tracks the **directory form** — each file as `NAME/` (one text file per record) beside `NAME.DICT/` (its dictionary) — while a **live account is hash files with no `.DICT`**. `mvx-git-adopt` turns the first into the second: ``` mvx-git-adopt directory form -> live hash files ``` **It goes one way only.** Every file is rebuilt as the backend its dictionary's `%FILE%` control record names — a hash file for an `lmdb` file, a directory file (keeping its `NAME.DICT`) for a `dir` file — then `BUILD` catalogs `BP` and links packages. `%FILE%` is the file definition, so a committed dictionary alone recreates its file. There is no export tool and no `--export` flag. The other direction is not a conversion step at all: `mvx-git` writes the directory form itself, from the live records, as part of `add`/`commit` (see [Version Control](git)). You rarely call `mvx-git-adopt` directly — **`mvx-git clone` adopts for you** when the clone carries a `.mvx` descriptor. Run it by hand only for a *plain* `git` checkout — the bootstrap that turns mvx-lang's own `system` account and packages into real accounts before `mvx-git` exists. This is the stock-to-site delivery path — combined with record-level branch, merge, and cherry-pick (see [Version Control](git)), a customised site tracks stock upstream and feeds features back. ## Record ids in the database An id is a byte string. Usually it is a key somebody typed; occasionally it has a mark or a raw byte in it. It is stored as **text**, so a record reads as itself in the tools a DBA already uses: ``` sales=> SELECT id FROM "ORDERS" ORDER BY id; id -------------- 50%25 OFF A%FDB Café INV-2026-001 ``` **Only the bytes the database's own character set cannot carry are escaped.** On a UTF-8 database `Café` is stored as itself and only a mark byte becomes `%FD`; on a LATIN1 or `SQL_ASCII` one almost nothing is escaped at all. Two bytes are escaped everywhere — `%` (written `%25`, which is what makes decoding unambiguous without a marker) and NUL — and one in one position: a **trailing space**, written `%20`, because a PAD SPACE collation would otherwise read `A` and `A ` as the same key. The character set is a property of the **stored data**, never of your session: Postgres asks `server_encoding` and MySQL the database charset, and each pins its session to match so nothing is transcoded on the way in or out. It is recorded beside the format — ``` sales=> SELECT obj_description('"ORDERS"'::regclass); mvx: format=3 idcs=utf8 idenc=UTF8 ``` — and a file whose stamp disagrees with the database it is now in is **refused by name** rather than half-read. That matters after a dump is restored into a differently encoded database: the text is re-spelled by the restore, so every key built afterwards would silently miss. (SQLite records the format in `PRAGMA user_version`; Mongo needs neither, because a BSON string is UTF-8 by definition.) Decoding never depends on any of this — it is one operation, forever — so an id written under any character set reads back byte-exact. ### Converting a database from an earlier release `mvx-doc-migrate` brings every file in a location up to the current format, in place, and is safe to re-run: ``` $ mvx-doc-migrate sqlite /srv/acct/acct.sqlite $ mvx-doc-migrate mysql 'host=db port=3306 user=app password=… dbname=sales' $ (cd /srv/acct && mvx-doc-migrate postgres @pgmain) $ (cd /srv/acct && mvx-doc-migrate mongo @mongomain) ``` It does two conversions: a pre-#157 record blob becomes a JSON document, and a binary record id becomes text. It also **re-spells** ids when the character set has changed under them, recovering the original bytes through the database's own inverse conversion (`convert_to` in Postgres, `CONVERT … USING` in MySQL) rather than guessing. Each table is judged by its own stamp, so a file already spelled for this database is left alone. A conversion that cannot be completed writes nothing and says which file and why. ## The migration curve ``` embedded LMDB -> networked LMDB -> (heavier backends) ``` Each step is configuration, not surgery, because the driver contract is the boundary. Migration can be per file: a directory file, an embedded file and a daemon file coexist in one account. ## Privilege and containers `$MVXPRIV` comes from the process environment — system-level configuration that account data cannot write. In containers, run app sessions stateless with the account's data on a volume or on a shared server, and grant `unrestricted` only where a shell escape is truly needed. ## Sessions and ports (`mvx-msgd`) MVX can keep a **roster** of who is logged on. It is optional: with no registry running, everything behaves exactly as it did before — `WHO` prints the user and the account, `@USERNO` is 0, and no program is affected. ```sh mvx-msgd -s /run/mvx/msgd.sock # the registry, one per host ``` Sessions find it through `$MVXMSGD` (default `/tmp/mvx-msgd.sock`). Each session that starts takes the **lowest free port number**, and `LISTU` shows the roster: ``` > LISTU Port User Account Logged on Line 1 fred SALES 09:14:22 /dev/ttys004 2 jane SALES 09:31:05 /dev/ttys007 2 session(s) on prefix SALES ``` **The connection is the lease.** A session holds its socket open for as long as it lives, so a clean logoff, a killed process and a dropped SSH all remove it from the roster with nothing to clean up. There are no stale entries to sweep. **A port is scoped by a prefix, which defaults to the account name**, so a user keeps the same address whichever host they log on to. Set it explicitly where several accounts should share one space, or where one should be split: ```sh mvx-msgd -s /run/mvx/msgd.sock -x SALES -b 1 ``` `-b` sets the lowest port number, and `-x` the prefix. A verb runs as a child of its shell and joins the port the shell already holds, so a command does not log a second session on. ### Messages between ports ``` > MSG !2 the backup has finished sent to 1 port > MSG * system going down at 18:00 sent to 4 ports > MSG SALES please log off > MSG @fred your report is ready ``` A port that is not logged on receives nothing, as classic Pick did, and `MSG` says how many ports it reached. Sending to one port is ungated; `*` needs an `msgwall` permit and a whole account an `msgbroadcast` one, checked in the runtime rather than in the verb — anyone who can compile could otherwise call `MSGSEND` directly. **A message never writes on a program's screen.** It waits in the session's inbox until the program asks for it, so a full-screen program keeps its form: ```basic LOOP K = KEYIN(250) IF MSGPENDING() > 0 THEN GOSUB SHOWMSG ;* paint it where you choose ... REPEAT ``` `examples/MSGDEMO.b` is a working example. A program that never calls `MSGPENDING` never sees one, which is what you want for a report that runs for an hour, and `MSGMODE("OFF")` has the daemon discard them on arrival — except a wall or system message, which a user may not switch off. At the TCL prompt a message appears **as it arrives**, even mid-command: the shell waits on the terminal with a deadline rather than blocking in a read, so an arriving message prints and the line you were typing is redrawn underneath it, untouched. There is no signal handler anywhere in this path — a deadline on a poll, not an interrupt. ### Sending from BASIC The intrinsics are in [Intrinsics](intrinsics.md). `SEND` and `MESSAGE` are the statement spellings, for the same thing: ```basic SEND "Backup starting" TO 7 THEN PRINT "sent" ELSE PRINT "port 7 is off" MESSAGE OFF ``` `SEND` takes a **port**, as D3 spelled it, and takes `THEN` when the message reached somebody — a user, an account or a wall goes through `MSGSEND`. Neither word is reserved: `MESSAGE` is exactly the name a program would give a variable, so each is a statement only in a shape that cannot be anything else, and `SEND = "x"` still compiles. The inbox holds 256 messages (64 KB) and drops the oldest, counting what it lost, so `MSGDROPPED()` can say so honestly. Sending is rate limited per port. A message is an ordinary MV record, so it needs no decoding: ``` 1 version 2 class + flags 3 from port 4 from user 5 from account 6 from host 7 sent 8 text 9 payload ``` ### Sending somebody a record A message can carry an attachment: where to find a record, so the person receiving it can open it. ``` > MSG !7 WITH ORDERS O1234 have a look at this sent to 1 port, with ORDERS O1234 ``` `MSG` refuses to send a pointer to a record that is not there — otherwise the receiver finds out by trying to open it, long after you have gone. On the receiving terminal: ``` [3] jane: have a look at this attached: ORDERS O1234 — on the stack as "CT ORDERS O1234" (.1 to recall) ``` **The terminal offers; it does not open.** The command goes on the stack and the record opens when you recall it and press return. A sender can hand you a screen, and cannot take yours. Attribute 9 carries, by value mark: ``` 1 file 2 record id 3 program 4 mode ("view" or "edit") ``` Positions 5 and beyond are free for an application, and a reader ignores what it does not recognise — which is what lets the convention grow without every reader being taught first. `MODE` picks the verb the shell offers (`CT` to look, `ED` to change); a named `PROGRAM` wins, because an application knows its own screens better than the shell does. A program builds one with `MSGSEND`: ```basic PAY = "ORDERS":@VM:ID:@VM:"ORDER.ENTRY":@VM:"edit" X = MSGSEND("@":WHO, "can you fix this one?", 0, PAY) ``` **Locks are a separate problem.** A screen written with edit and read-only modes should offer the *view* mode when the record is locked elsewhere; that is application behaviour, not something the runtime decides. ### Messaging across hosts A single host needs no service at all: `mvx-msgd` with its built-in `loop` transport is the whole system. To reach ports on other machines, point each host's daemon at a message broker: ```sh mvx-msgd -s /run/mvx/msgd.sock -t mqtt -c tcp://broker:1883 -x SALES -b 1 ``` - `-t` names the transport. `loop` (the default) is one host and no service; `mqtt` is built when libmosquitto is present (`MVX_WITH_MQTT`). - `-c` is the broker. Credentials come from the daemon's environment — `MVXMSG_USER` and `MVXMSG_PASSWORD` — so they never reach a client process. - `-x` is the port prefix, shared by every host that should see one another. - `-b` is the lowest port number this host hands out. **Give each host its own block** (1, 201, 401), so two daemons never allocate the same number. `docker/compose.msg.yaml` runs a broker and two application containers, each with its own daemon, as a working example. **The roster spans hosts.** Each daemon announces its sessions on a retained topic and clears them when they go, so `WHO` and `LISTU` list every machine sharing the prefix, and `MSG !201`, `MSG @fred` and `MSG SALES` all resolve against the whole system and report a real count. A daemon that *dies* cannot clear anything, so it registers a **last will**: an empty message on its own topic, which every other daemon reads as "forget what came from there". Its sessions leave the other rosters within seconds, without anyone waiting for a timeout. Where a service offers neither retained messages nor a will, the daemon falls back: sessions are re-announced periodically and entries that go quiet expire. The roster is then a little slower to settle, and just as correct. **A broker that is down does not take messaging away.** Sessions on the same host go on messaging each other, publishes are buffered and flushed on reconnect, and `MSGSTATUS()` reads `degraded` rather than `up`: ``` > MSG !2 still works sent to 1 port ``` The transport is a driver, like a storage backend: `libmvxmsg_mqtt` is the only thing that links libmosquitto, and another service can be added without changing anything above the daemon. ### Who is logged on, and what counts as one session **Every program is present**, whether or not it ever sends a message. A report that runs for an hour shows up in `WHO`, and a message sent to its port has somewhere to go. Registering only programs that use messaging would make the roster a list of the programs that use messaging. **One person at one terminal is one port.** A verb, a program run with `EXECUTE`, and a cataloged subroutine all join the session they were started from rather than taking a port of their own: ``` > PORTSHOW port=1 execute=1 > LISTU 1 fred SALES app1 09:14:22 1 session(s) on prefix SALES ``` **A program that crashes returns you to TCL, still logged on.** The shell holds the session; a program only attaches to it. The port, the roster entry and any queued messages survive. Only when the shell itself goes does the session end — and then it ends by itself, because the connection is the lease.