Publishing
The Publishable capability (resources): the standard for making a resource publicly shareable — a versioned publish copy plus a public, rate-limited, read-only route. Whenever a product needs "share this with people who aren't logged in", it opts into this capability — never ad-hoc public reads of working data.
Adopters: Dashboard, Email, Flowchart, Note, Survey, Webpage. A type opts in by declaring publishable: true in ResourceDefinitionMap; the derived PublishableResourceType union then requires it to provide a view component and grants it the publish procedures — a non-publishable type has no publish endpoints at the type level.
How it works
Publish state lives in its own resource_publications table (resources) — a row exists iff the resource is currently published. This keeps publish attributes off resources that can't publish.
- Publish = snapshot copy.
publishResourceupserts theresource_publicationsrow (bumpingpublishVersionin SQL), then stores the content as a published version — a row and a content-addressed object (resource version store). Edits after publish are invisible until re-publish — that is the feature (a stable public artifact), not a limitation. - Public reads serve only the publish copy, never the working copy, and are rate-limited with no auth. A resource with no publication row 404s publicly.
- Unpublish deletes the publication row, the published channel's version rows and their asset clones; the public URL 404s.
sequenceDiagram
actor Owner
participant R as {type} router
participant PG as resource_publications
participant BLOB as resource-assets
Owner->>R: publishResource(id)
R->>R: transformPublishedContent(ctx, resource, content)
R->>PG: upsert row, bump publishVersion
R->>BLOB: store published version {publishVersion}
Note over BLOB: immutable snapshot — later edits invisible until re-publish
actor Viewer
Viewer->>R: readPublishedResourceContent(id) — public, rate-limited
R->>PG: 404 unless publication row exists
R->>BLOB: serve published version {publishVersion}
Procedures
| Procedure | Auth | Purpose |
|---|---|---|
publishResource | owner | upsert publication + snapshot copy → ResourcePublication |
unpublishResource | owner | delete publication row + published versions and clones |
readResourcePublication | owner | current publish state (for editor UI), or undefined |
readPublishedResourceContent | public, rate-limited | serve the publish copy |
readPublishedVersionContent | owner | serve a retained snapshot by version number |
readResourceViewCount | owner | total public views of the resource |
Publish state is also carried by the cross-type resource.readResource, whose publication field is the row or null — resource_publications is one table for every type, so the generic read resolves it whatever the resource turns out to be, and the ownership a separate publication read would resolve is the ownership that request already resolved. null is the answer "not published" rather than a missing one, so a surface opening a resource learns its publish state from that one response instead of following it with a second round trip. readResourcePublication remains the targeted re-read for a caller that wants publish state on its own.
readPublishedVersionContent is what the view route's version query param reads — useReadPublishedResourceContent reads the param itself and takes the versioned read as a required argument, so no type's view can serve the latest publish under a url naming another version: a url with no valid version param gets the latest publish from the public procedure, a url with one reads through the owner-only procedure — so an anonymous visitor handed a versioned url is refused rather than shown the latest — and the owner can open any published version whose row an unpublish has not removed (resource snapshots).
One hook on createResourceProcedures supports publishing needs:
transformPublishedContent(ctx, resource, content)— rewrite content at publish time with the owner's authority. It runs before the transaction that claimspublishVersion, and must stay there: a hook may read throughctx.db(Dashboard resolves every bound dataset), and issuing that read while the connection holds an open transaction deadlocks. So nothing the hook writes may be keyed by the version — it would have to predict it, two concurrent publishes predict the same one, and they race a copy destination Azure rejects, unwinding a publish that did nothing wrong. Asset clones therefore go into a per-attempt directory (createSnapshotAssetsDirectoryName), which nothing reads a version back out of; the snapshot's own content is what points at them. Dashboard resolves every bound visual and bakes the result, cut to the columns its query reads, intoVisualDatasetBinding.snapshot(public viewers render the static snapshot, never resolve references — live viewer data stays deferred). Survey and Webpage use the generictransformPublishedBlobUrls, which clones referenced asset blobs into the publish directory and rewrites their stable urls to the clones (resource file assets); Email composes it with a guard that rejects publishing without compiled MJML html and strips the owner-onlydatasetReferenceso the snapshot can never leak it.
Running before the transaction is also what lets anunpublishResourceland between the clone and the claim: its prefix sweep is bounded at the instant it was decided, so it takes the clones this attempt just wrote, while the snapshot content — written inside the transaction, past that bound — survives, and the upsert re-creates the publication row. The resource would report itself published with every image 404ing, and no operation the owner would think to run rebuilds it.publishVersionis what detects this, and it is exact: the sweep only ever follows a row delete, and the delete restarts the sequence at 1, so a claim that is not the successor of the version the attempt read before cloning proves one landed. An attempt that read no row expects to claim 1, exactly as one that read version 3 expects 4 — the check is on the successor, never on there being a previous row, or every first publish would be exempt from it.publishResourcere-runs the transform and re-uploads the snapshot when it sees that, writing the clones past the sweep's bound. A concurrent publish also breaks the succession and pays one redundant clone; nothing swept can slip through, because any successor the attempt could expect is at least 2.
That repair runs after the transaction has committed, and cannot move inside it for the same deadlock reason the transform itself cannot. The transaction's guarantee is unaffected — the version it claimed does point at an object that was stored — so what a failed repair leaves behind is not a dangling version but that version still naming the swept assets: a live publication whose images 404. The rejection is therefore reported to the owner rather than swallowed, and says exactly that, because republishing re-clones and overwrites — the owner's own retry is the repair, and a silent success would leave the page broken with nothing to signal it.
The succession is only a complete signal becauseunpublishResourcesweeps only when its delete actually removed a row. A delete that removes none leaves the sequence untouched, so an unpublish fired from a stale tab against an unpublished resource would sweep a bound stamped after a concurrent first publish's clones, and nothing downstream could tell. Nothing was published, so there is nothing of its own for it to sweep.
Running before the transaction also means the hook's writes are not rolled back with it: a publish that fails after the clone leaves that attempt's asset directory orphaned, and because the directory is per-attempt a retry never overwrites it — a user retrying a failing publish pays for one copy of their assets per attempt. Accepted: unpublish and delete both wipe the whole{id}/publishedprefix, so nothing leaks past the resource's own lifetime, and the alternative is either a version-keyed directory (which two concurrent publishes race) or a compensating delete on a path that already failed.
The read half of the same boundary is not a hook here at all: a type declares what stays live inside a frozen snapshot, and one shared reconstitution reapplies it on every path that reads one back (resource snapshots). Only Survey declares anything, merging its live collection settings over the immutable snapshot. Asset urls need no read-time rewriting — content embeds stable app urls that never expire, served through /api/resource-assets.
Route
One dynamic public page, pages/view/[type]/[id].vue, dispatches through ViewComponentMap: Record<PublishableResourceType, Component> — a missing renderer is a compile error. The survey respondent experience is simply Survey's published view (an interactive renderer that writes responses); Email and Webpage serve their save-time captured HTML through a sandboxed iframe, and Flowchart a read-only VueFlow render. The OG title is set by useReadPublishedResourceContent — the shared fetch-or-404 every view reads through — off the resource name it just read, so a published URL unfurls when shared and no view restates the derivation. The rest of the unfurl is resolved per route by @nuxtjs/seo. A published URL is the share unit everywhere: paste it in an esbabbler message, a post, or externally.
Key files
| File | Role |
|---|---|
packages/db-schema/src/schema/resource/resourcePublicationsInResource.ts | publish state table |
apps/web/server/trpc/procedure/resource/createResourceProcedures.ts | publish procedures + transform hooks |
apps/web/app/pages/view/[type]/[id].vue | public view route |
apps/web/app/composables/resource/useReadPublishedResourceContent.ts | shared view read — fetch-or-404 plus the og meta |
apps/web/app/services/resource/ViewComponentMap.ts | PublishableResourceType → view page component |