Skip to content

docs(publishing): make the Publisher guides match the shipped server#331

Open
mlennie wants to merge 11 commits into
malloydata:mainfrom
mlennie:monty/docs-fix-publisher-guides
Open

docs(publishing): make the Publisher guides match the shipped server#331
mlennie wants to merge 11 commits into
malloydata:mainfrom
mlennie:monty/docs-fix-publisher-guides

Conversation

@mlennie

@mlennie mlennie commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

The five Publisher pages had drifted far enough that following them mostly did not work. Every fix here was verified by running it against the published @malloy-publisher/server@0.0.228 and @malloy-publisher/sdk@0.0.228, not by reading the source. Where the two disagreed, the live behaviour won, several times.

What was broken

The quick start served nothing. The page said local files need no config file. They do: Publisher only serves packages that publisher.config.json lists, and there is no directory scan. Following the page exactly produced environments: [] and the package was never discovered, while the page's own diagram showed a config file it never told you to create. Added the missing step.

Both REST examples returned 404. They posted to a queryResults/{model} path that does not exist and never has, so they failed regardless of which environment or package you substituted. The endpoint is POST /api/v0/environments/{env}/packages/{pkg}/models/{path}/query. The JavaScript example also sent the query as a ?query= string on a GET, and the route is POST only. The documented response was wrong in a way that broke the sample code: result is a JSON string, not an array, so return data.result handed callers a string where they expected rows.

The SDK example crashed at render. It passed optionalPackageName and optionalProjectName, which were removed in September 2025 and replaced by resourceUri. The old payload throws Invalid URL when the component parses it. Both names survive as local variables inside the component, which is why a grep does not catch this. ServerProvider also takes baseURL, not server, so both provider examples were silently ignored.

The Explorer URL dropped the file extension. /examples/storefront/storefront renders Unrecognized file type: storefront; /examples/storefront/storefront.malloy opens the Explorer. Three documented hover actions do not exist in the shipped build: "Add as Sort", "Add to Query" and "Add as Nested Query" have no hits in the malloy-explorer bundle, while "Add as order by", "Add view" and "Add as new nested query" do.

The connections page talked readers out of a working feature and into pasting credentials. It said Publisher does not support environment variable substitution and to put real credential values in the config file. ${VAR} substitution works on every string in the config. Its attached-database examples also declared an environment-level connection named duckdb, which is reserved: that config logs an error, skips the whole environment, and leaves the server reporting "operationalState": "serving" with nothing loaded. It also documented a presto connection type that does not exist.

The docs site documented a deleted feature. PG_CONNECT_TIMEOUT_SECONDS was removed from Publisher along with the code that read it. The README dropped the same row already; this brings the site back in sync.

Also fixed: /api/v1/projects (there is no v1); a bare /status health check that is not a route; Docker examples mounting packages over /publisher/packages, which is the server's own code inside the image; a dead README#configuration anchor; ~/package locations, which never worked because Publisher does not expand a tilde; missing gs:// and s3:// package locations; and the example-app steps, which crashed because the app resolves react and @emotion from the repo-root node_modules and the steps never installed there.

Two things worth a maintainer's attention

Model edits need --init. The page said to restart to pick up a change. Publisher serves a copy, so a plain restart still serves the old model. --watch-env on its own does nothing once publisher_data holds that copy, and /api/v0/watch-mode/status reports "enabled": true the whole time it is ignoring your edits. --init --watch-env is what works.

The unauthenticated API serves credentials and runs SQL. Substituting ${VAR} keeps a password out of the config file and out of git, but not out of the server: /api/v0/status, /api/v0/environments and the connection endpoints all return the resolved password in plaintext, and Publisher ships with no authentication and binds all interfaces. The exposure is not limited to servers that hold credentials, because the DuckDB sandbox every package gets automatically will read files off the host, so even a Publisher with no connections configured exposes its filesystem. The API is read-write by default, and frozenConfig: true closes config writes but not queries.

This mattered for the guide itself: the Docker Compose healthcheck curled /api/v0/status, which meant fetching database passwords every thirty seconds and handing them to Docker, which keeps healthcheck output in the container's health log. It now uses /health, which reports liveness and readiness and nothing else. The pages describe the exposure rather than implying environment variables are a security control.

On projects vs environments

The /api/v0/projects/... paths in these pages do not 404. server-old.ts registers 42 legacy /projects/... routes against the same controllers so existing SDK clients keep working. The examples now use environments because that is the documented surface and the legacy one carries a "remove this during projects cleanup" TODO, but that change is a staleness fix, not a broken-link fix. The REST examples were broken for a different reason: queryResults/ is not a route under either noun.

Verification

Every command, curl, config block and code snippet was extracted from these files programmatically and executed, rather than retyped. The REST examples reproduce their documented response byte for byte, the quick start now yields one environment and one package where it previously yielded none, the SDK snippet typechecks against the published SDK and round-trips at runtime, and all four Malloy queries run against the shipped storefront package. The Explorer URLs and labels were checked in a browser and against the shipped malloy-explorer bundle.

Reviewing this branch found real defects in the fixes themselves, which is worth saying plainly. The SDK example typechecked cleanly and still returned 400 'brand' is not defined, because a Malloy query is a string and invisible to the typechecker; brand lives on the joined products source. The watch-mode note documented a command that had never been run. Both were caught by executing rather than reading, which is the same reason the pages needed this work in the first place.

mlennie added 11 commits July 16, 2026 18:12
Four fixes, all verified against the published @malloy-publisher/server@0.0.228.

Environment variable substitution: the page said Publisher does not support it
and told readers to put real credential values in the config file. Both halves
are wrong. config.ts substitutes any ${VAR} in a string value, including package
locations, so credentials can stay in the environment. Documented the actual
matching rule (uppercase, braced only) and the failure mode when a variable is
missing: the config silently fails to load and the server comes up serving
nothing while /api/v0/status still reports "serving".

Reserved connection name: the attached-database examples declared an
environment-level connection named "duckdb", which is reserved for the
per-package sandbox. Booting that config logs "Error initializing environment
...; skipping environment" and drops the entire environment, so the server
reports "serving" with zero environments. Renamed to shared_duckdb, updated the
Malloy source line that referenced it, and added a note so the name does not get
changed back. The parquet example's duckdb.table(...) is the package sandbox and
is left alone.

Presto: not a supported connection type. The set is postgres, bigquery,
snowflake, trino, databricks, mysql, duckdb, motherduck, ducklake. Dropped the
sentence and retitled the section to Trino.

Config key: the examples used the deprecated top-level "projects" key. It still
works via back-compat, but it warns on every boot and this page is where readers
copy their config from, so the examples now use "environments".
Both examples on this page posted to a `queryResults/{model}` path. That path
does not exist and never has, so both 404ed regardless of the environment or
package you substituted. The endpoint is:

  POST /api/v0/environments/{env}/packages/{pkg}/models/{path}/query

The JavaScript example also sent the query as a `?query=` string on a GET. The
route is POST only and reads the query from a JSON body, so that request 404ed
too.

The documented response was wrong in a way that broke the sample code: `result`
is a JSON string, not an array, so the old `return data.result` handed callers a
string where they expected rows. The example now parses it, and the page says so
explicitly. Added a note on `compactJson`, which is what produces the plain array
of row objects the page was already showing.

The examples now use the samples Publisher serves out of the box, so a reader can
run them as-is against `npx @malloy-publisher/server`. Both were verified by
extracting them from this file and executing them against a real server: the curl
returns the documented response byte for byte, and the JavaScript returns an
actual array.
The quick start said local files need no config file. They do. Following the
page exactly (create a package, run npx --server_root .) produced a server with
zero environments: /api/v0/environments returned [] and the package was never
discovered, because Publisher only serves packages that publisher.config.json
lists. There is no directory scan. The page's own diagram already showed a
publisher.config.json that step 1 never told you to create. Added the missing
step, and renumbered the two that follow.

Other fixes on the page:

- The health check and the Compose healthcheck both used a bare /status. That is
  not a route. The web app's catch-all answers it with 200 and an HTML page, so
  "curl -f http://localhost:4000/status" succeeds even when the server has loaded
  nothing at all, which makes the container healthcheck incapable of failing.
  Both now use /api/v0/status.
- "List Projects" curled /api/v1/projects. There is no v1 API, so that also fell
  through to the web app and returned HTML rather than JSON.
- Dropped the PG_CONNECT_TIMEOUT_SECONDS row. The variable was removed from
  Publisher along with the code that read it; the README dropped the same row
  already.
- The Docker examples mounted packages over /publisher/packages, which is the
  server's own code inside the image.
- Repointed a dead README#configuration anchor at docs/configuration.md.
- "Restart to pick up model edits" does not work: Publisher serves a copy, so a
  plain restart still serves the old model. Documented --init and --watch-env,
  and noted that --init deletes publisher_data without prompting.
- Added gs:// and s3:// to the package locations, and updated the deprecated
  "projects" config key and prose.

Verified against @malloy-publisher/server@0.0.228 by extracting the config blocks
from this file and running the page's own commands: the quick start now yields
one environment and one package, where it previously yielded none.
The EmbeddedQueryResult example passed optionalPackageName and
optionalProjectName. Those props were removed from the SDK in September 2025 and
replaced by a single resourceUri, so the example addressed nothing and threw
"Invalid URL" when the component parsed it at render. Verified against the
published SDK 0.0.228: the old payload throws, the new one round-trips. They are
easy to miss in a grep because both names survive as local variables inside the
component.

The page also said the two names are only needed when the server holds multiple
projects or packages. A resource URI is always required.

Other fixes:

- ServerProvider takes baseURL, not server. There is no server prop, so both
  examples were silently ignored and the provider fell back to its default. The
  CORS walkthrough carried the same wrong name, which is the line most local
  setups copy.
- The default is not http://localhost:4000. The SDK derives /api/v0 on whatever
  origin serves the page, which is why the example app proxies through Vite.
- --server_root ./malloy-samples pointed at a directory the publisher repo does
  not contain; packages/server holds the config that serves the example packages
  the app expects.
- Dropped the .env line from the project layout. VITE_PUBLISHER_API appears only
  in .env.example and no source file reads it; the URL comes from the Vite proxy
  or from baseURL.
- The renderer does not pick charts on its own. Results are tables unless a
  render tag says otherwise.
- Corrected the example app's view names to the ones it actually ships.

The new example was extracted from this file and typechecked against the
published SDK, then exercised at runtime.
The URL format left out the file extension. The web app routes
:environmentName/:packageName/* and then branches on the extension, so
/examples/storefront/storefront renders "Unrecognized file type: storefront"
while /examples/storefront/storefront.malloy opens the Explorer. Both were
loaded in a browser to confirm. Also named the first segment for what it is, an
environment.

Three of the documented hover actions do not exist in the shipped Explorer.
Checked against the malloy-explorer build the SDK depends on: "Add as Sort",
"Add to Query" and "Add as Nested Query" have no hits in the bundle, while "Add
as order by", "Add view" and "Add as new nested query" do. A reader scanning the
hover menu for "Sort" will not find it, because Malloy's concept is order by.
Confirmed live as well: hovering a field shows "Add as group by", and the order
by action reports "Order by is only available for fields in the output", which is
a restriction the page did not mention. Lowercased the remaining labels to match
what the UI actually renders.
Four corrections, three of them to text this branch itself added.

The SDK embedding example did not compile. It grouped by `brand`, which belongs
to the joined `products` source and is not reachable unqualified, so the query
returned 400 "'brand' is not defined". The model's own top_brands view uses
`products.brand`. The snippet typechecked against the SDK, which is why this
survived: the types were right and the query inside them was not. Every Malloy
query on these pages is now executed against the shipped storefront package.

The reserved-connection-name note said the server "refuses to start" when an
environment-level connection is named `duckdb`. It does not. It starts, logs the
error, skips the whole environment, and keeps reporting "serving" with nothing
loaded, which is the same silent failure these pages warn about two sections
earlier. Corrected to the real behaviour.

Removed `~/package` from the package locations. Publisher never expands a tilde
(no homedir handling exists), so a `~/...` location makes the server stat a
literal `~` directory and the package fails to load. The claim was pre-existing,
but this branch added "resolved against --server_root" next to it, which makes it
self-refuting.

Dropped `cp .env.example .env` from the example-app steps, which contradicted the
new line stating no environment variable points the SDK at a server, and made the
JavaScript comment show the exact value the endpoint returns rather than a
rounded one, since the page prints the raw float a few lines earlier on purpose.
…d claims

The environment-variable section told readers that ${VAR} keeps credentials out
of the config file, and left it there. That is true but it is not the whole
story, and the missing half is the security-relevant one: Publisher's connection
endpoints return the resolved configuration, password included, to anyone who can
reach them, and Publisher ships with no authentication and binds all interfaces.
Verified against 0.0.228 by putting a sentinel password in an environment
variable and reading it back in plaintext from an unauthenticated
GET /api/v0/environments/default/connections. Substituting a variable protects
the file and your git history, not the server, and the page now says so.

Also noted that a variable which is set but empty is not an error: only a
completely unset one fails the load, so a blank ${PG_PASSWORD} becomes a blank
password rather than a failure.

Three claims this branch added were overstated:

- The Compose healthcheck note implied /api/v0/status reports whether the server
  is serving anything. It returns 200 as soon as the process is up, including
  with zero environments loaded, so it checks liveness only.
- The troubleshooting step named two log strings, neither of which is emitted
  when the config itself fails to parse. That case logs "Error reading
  publisher.config.json", which is exactly the case a missing variable produces.
- The REST quick example works against the bundled samples only for a
  zero-argument start, because any --server_root or --config disables the bundled
  default config. The precondition is now stated.

Finally, the Vite proxy example forwarded /api while the surrounding prose and
the real example app both use /api/v0.
…strings

More defects found reviewing this branch, most of them in text the branch itself
added.

The example-app walkthrough crashed. examples/data-app resolves react and
@emotion from the repo-root node_modules (its vite config aliases them there),
but the steps never installed at the root. Reproduced on a fresh checkout: bun
run dev exits 1 with "Cannot read file" for each aliased package; with a root
install present the dev server comes up on 5173 with no alias errors. The example
also needs its own install, since it is not part of the workspace, so both are
now listed. Use bun at the root: the repo ships only bun.lock and its engines
require bun.

Watch mode was documented as an alternative to --init when it is not one. Once
Publisher has copied a package into publisher_data, adding --watch-env on its own
does nothing: the in-place mount is only set up when that copy is absent, so
edits are still ignored, and /api/v0/watch-mode/status reports "enabled": true
the whole time. Verified by isolating the flag: --watch-env alone leaves the old
result, --init --watch-env picks the edit up live. The instruction is now
--init --watch-env.

The troubleshooting strings were mapped to the wrong causes. A config that fails
to parse logs "Failed to parse", never "Error reading publisher.config.json";
that second string is what an unset ${VAR} produces. A reader with a malformed
config would have grepped for a string the server never prints. Both are now
listed against the failure that emits them.

The credential note scoped the exposure to the connection endpoints. It is wider:
/api/v0/status and /api/v0/environments return the resolved password too, and
/api/v0/status is the endpoint this guide tells you to health-check. Verified by
reading a sentinel password back from each. The deployment section, which had no
authentication caveat at all, now carries one.

Also corrected two Explorer labels the earlier sweep missed: the menu item is
"Nest query", and the Add Query Element list is sentence case like the rest.
The caveat this branch added was scoped to credentials, and the scoping let the
most-recommended deployment off the hook. "A Publisher that holds credentials
belongs on a trusted network" implicitly exempts the DuckDB path these guides
push hardest, the one where no connection is configured at all. That deployment
is exposed too.

Verified against the published server, unauthenticated, on a config declaring no
connections whatsoever:

  POST /api/v0/environments/default/packages/pkg/connections/duckdb/sqlQuery
    {"sqlStatement":"SELECT * FROM read_csv_auto('/tmp/secret-probe.csv')"}
  -> 200 {"rows":[{"col":"SENSITIVE-HOST-FILE-CONTENTS"},...]}

The DuckDB sandbox every package gets automatically will read files off the
server's filesystem, so the exposure does not depend on having a database
configured.

The API is also read-write by default, which the caveat did not say. An anonymous
POST created a connection (201) and the change persists to publisher.db.
frozenConfig: true is what closes that (403), so the Mutable vs Frozen section now
names it as a security control for any Publisher reachable beyond localhost,
rather than a production nicety. It is not sufficient on its own: with
frozenConfig: true the same arbitrary SQL still runs, so the port still has to be
kept off untrusted networks.
…/status

This branch fixed the Compose healthcheck from a bare /status to /api/v0/status,
and then documented, on the connections page, that /api/v0/status serves the
resolved connection configuration with the password in it. Both statements are
true, which made the healthcheck a standing instruction to fetch database
credentials every thirty seconds and hand the output to Docker, which keeps it in
the container's health log.

/health exists in the same build, returns {"status":"UP"} and nothing else, and
works with curl -f. Verified with a sentinel password in the config: /api/v0/status
returns it, /health, /health/readiness and /health/liveness do not. The Compose
healthcheck and the Verify It Works check now use /health, and the note explains
why rather than only warning about the bare /status route.

The environment listing still shows connection configuration, so the guide keeps
recommending /api/v0/environments only as something you run yourself against your
own server, which is also what the connections page now describes.
…wrong one

The healthcheck note said /health returns just {"status":"UP"}. That is
/health/liveness's body. /health returns a liveness and readiness object with
components and groups. The point the sentence makes is unaffected, since neither
body contains connection configuration, but it quoted a response the server does
not produce, so it now describes the response rather than pinning a literal.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant