Navigation

Resources

The standard for product persistence and product surface. Everything is a resource: a file, a survey, a todo list, a dashboard, an email, a webpage, a flowchart. One Postgres table, one blob container, one procedure factory, one explorer UI. Cross-cutting behaviors (publishing, dataset serving, asset hosting, import/export) are opt-in capabilities, never baked into the core.

Anatomy

A resource is three things: an identity row (Postgres), a content blob (Azure Blob), and a definition (shared code).

flowchart LR
  subgraph pg [Postgres — identity and lifecycle]
    ROW["resources row<br/>id · type · name · userId · contentVersion"]
    PUBROW["resource_publications row<br/>exists iff published"]
    ROW -. "1:0..1, Publishable only" .-> PUBROW
  end

  subgraph blob ["Azure Blob resource-assets — every path under {id}/"]
    CONTENT["content.json — the working copy"]
    PUB["objects/{hash} — every retained version, keyframe or delta"]
    FILES["files/… — binary assets, FileAssets only"]
  end

  DEF["ResourceDefinitionMap entry"]

  ROW -- "id is the blob path prefix" --> CONTENT
  CONTENT -- "a publish or a revision takes a version" --> PUB
  DEF -- "contentSchema validates" --> CONTENT
  DEF -- "capabilities gate procedures, blades, commands" --> ROW

Settings vs data is a UX separation, not a storage separation. A resource's parse settings and its actual data are distinct sections of one content blob, edited in distinct blades, saved through one procedure with one contentVersion. Never split one artifact across two write paths.

Data model

Drizzle table resources (packages/db-schema/src/schema/resource/resourcesInResource.ts) — pure identity + content lifecycle:

ColumnTypeNotes
iduuid PKbecomes the blob path prefix
typeResourceType pg enumBlueprint, Dashboard, Email, Flowchart, Note, Program, Sheet, Survey, TodoList, Webpage
nametext + length checkcreateNameSchema pattern; a trigram GIN index backs similarity ranking in search
tagsjsonb, not null, {}free-form key/value labels; a GIN index backs the tags @> input containment filter
userIdFK → users, cascadeowner; resources are single-owner
contentVersionintegeroptimistic concurrency on content saves

Publish state is normalized into its own table, resource_publications — a row exists iff the resource is currently published. Publishing is a capability, not a base attribute, so publish columns do not belong on every resource row:

ColumnTypeNotes
resourceIduuid PK, FK → resources, cascadeone publication per resource
publishVersioninteger, default 1the published channel's version row that is live
publishedAttimestamp, default nowwhen the current publish happened

Content blobs live in one container, AzureContainer.ResourceAssets, keyed by id only (type lives in the row; ids are UUIDs — a type prefix would duplicate authoritative data into path strings):

{id}/content.json                      working copy (JSON as a zstd frame, validated by the type's content schema)
{id}/objects/{hash}                    every retained version's content, in either channel (the version store)
{id}/published/{publishId}/files/…     a published version's asset clones (Publishable only)
{id}/files/…                           binary assets (FileAssets types only)

Ownership is enforced through the Postgres row, never inferred from the blob path. Deleting a resource is soft — identically for every type: it stamps deletedAt and drops the publication row, leaving the {id}/ blob directory intact so a restore can hand the content back. Purging is what deletes the directory and then the row (recycle bin).

Each type owns one content schema (Zod, interface-first, one export per file) in apps/web/shared/models/. A content schema always produces an object (never a bare string/array) so future fields extend without a blob-shape break.

Capabilities

A capability is a cross-cutting mechanism a resource type opts into via its definition. Admission rule: a capability exists only when ≥2 resource types need the same mechanism, or when the type system must guarantee its absence (a TodoList must not have publish endpoints). Anything used by exactly one type is type-specific code — promoting a single-consumer mechanism is over-engineering.

CapabilityContractAdopters
Publishableversioned snapshot + publish procedures + /view/[type]/[id] route + Publish command → publishingDashboard, Email, Flowchart, Note, Survey, Webpage
DatasetProviderregisters a provider so dataset.readDataset resolves the type → datasetsProgram (participant status), Sheet, Survey (responses)
FileAssetsowner-only upload/download/delete of binary assets under {id}/files/… → resource file assetsEmail, Survey, Webpage
Portableimport/export via declared formats (self-contained export() / import()) + Import/Export commandsSheet (csv/json/xlsx, both ways), Email (personalized html export only), TodoList (print export only)

Explicitly not capabilities: collecting public responses (Survey-only — stays survey-specific code) and dataset consumption (just calling dataset.readDataset from a component; no per-type wiring to declare).

Declaration — ResourceDefinitionMap

One shared as-const-satisfies map (apps/web/shared/services/resource/ResourceDefinitionMap.ts) is the single source of truth for what a type is: its contentSchema, icon, title, and capabilities ({ datasetProvider?: true; fileAssets?: true; portable?: true; publishable?: true }), keyed by ResourceType.

A generic mapped type derives the subset of types declaring each capability:

// apps/web/shared/models/resource/CapabilityResourceType.ts
export type CapabilityResourceType<TCapability extends keyof ResourceCapabilities> = {
  [T in ResourceType]: (typeof ResourceDefinitionMap)[T]["capabilities"] extends Record<TCapability, true> ? T : never;
}[ResourceType];

PublishableResourceType, FileAssetsResourceType, and PortableResourceType are its aliases, and checkHasCapability(type, capability) is the matching runtime type guard. Capability implementation maps are keyed by the derived unions — ViewComponentMap: Record<PublishableResourceType, Component>, PortableFormatMap: Record<PortableResourceType, …> — so a missing view page or format entry is a compile error, and adding one for a non-capable type is also a compile error.

Wiring

flowchart TB
  DEF["ResourceDefinitionMap[type].capabilities"]

  DEF -->|"derives literal unions"| UNIONS["PublishableResourceType<br/>FileAssetsResourceType<br/>PortableResourceType"]

  subgraph server [Server]
    FACTORY["createResourceProcedures(type, options?)"]
    BASE["base procedures — CRUD, read and save content"]
    PUBP["publish procedures"]
    FAP["file-asset procedures"]
    DPM["DatasetProviderMap"]
  end

  subgraph client [Client]
    BLADES["ResourceBladeDefinitionMap"]
    CMDS["Toolbar commands — Publish · Import · Export"]
    VIEWS["ViewComponentMap"]
    FMT["PortableFormatMap"]
  end

  UNIONS -->|"conditional return type:<br/>a procedure exists iff the type declares it"| FACTORY
  FACTORY --> BASE
  FACTORY -.->|"publishable types only<br/>(compile error otherwise)"| PUBP
  FACTORY -.->|"fileAssets types only"| FAP
  UNIONS --> VIEWS
  UNIONS --> FMT
  FMT --> CMDS
  DEF --> DPM
  DEF --> BLADES

Component wiring cannot live in shared code, so the client keeps one thin satellite map per surface a type can hand a component to: ResourceBladeDefinitionMap (type-specific blades), ResourceEditorComponentMap (the inline Editor blade), ResourceOverviewComponentMap (an Overview blade richer than the generic one) and ViewComponentMap (public view renderers). PortableFormatMap sits beside them and is deliberately not one of them: its entries are the import and export handlers a type offers the command bar, not components. A component map is for components only — everything else procedural reaches a type through its own router name (useResourceRouter), so a create, a rename or a publish never needs an entry anywhere. The two maps whose entries carry a vendor canvas — ResourceEditorComponentMap and ViewComponentMap — hold defineAsyncComponent loaders rather than imports, because a static entry lands every type's editor in the chunk of whoever reads the map: opening one resource would download all of them. Server-side hooks (publish transform, read transform) are passed at router construction because they import server code.

Procedures

One factory, createResourceProcedures(type, options?) (server/trpc/procedure/resource/createResourceProcedures.ts), spread into each type's router. Content schema and container come from ResourceDefinitionMap[type] — callers never pass them. Publish procedures are spread conditionally with a conditional return type (guarded by checkHasCapability(type, "publishable") at runtime), so a non-publishable type's router has no publish endpoints at the type level — a compile error on the client $trpc type, a 404 on the wire. The options argument itself is a conditional tuple: publish hooks are only accepted when TType extends PublishableResourceType.

ProcedureAuthPurpose
createResourceauthedmetadata row; content blob written on first save
readResourcesauthedper-type offset-paginated list, publication state joined along
updateResourceownermetadata edit — { id, name?, tags? }, at least one of the two
deleteResourceownersoft delete — stamps deletedAt, blobs survive until purge
readResourceContent / saveResourceContentownerblob read/write with contentVersion check
onSaveResourceContentownersubscription — streams each save's content to other devices
the publish setsee publishingPublishable types only

updateResource replaces the whole tags record rather than merging it (Azure's own tag update semantics), and only a changed name writes a Renamed activity entry — a tags-only edit is not a rename, so it leaves no trail entry. Both editable fields are optional (at least one is required) so a caller writes only the field it owns: a rename and a tag edit are independent writes to one row, and a tag edit that had to restate the name would put the pre-rename name back whenever the two overlap.

saveResourceContent bumps contentVersion and writes the blob in one transaction — the version check is part of the UPDATE's WHERE, so concurrent saves cannot both pass and silently lose a write, and a failed blob upload rolls the version back.

Every content write funnels through the saveResourceContent service (server/services/resource/), not just the procedure of the same name: the editor's save, blueprint deploy, duplicate and restore all write their content through it. It parses the caller's content against ResourceDefinitionMap[type].contentSchema and then performs the blob write, the resourceEventEmitter emit, the activity entry and the type's after-save hook as one unit, because a path that writes content and misses one of them leaves a resource whose reminders, schedules or derived state exist or not depending on which door its content came through.

The parse belongs to that unit for the same reason. content arrives as unknown and the hook reads it as the type's own shape, so a caller that hands over content it never parsed — a blueprint manifest carries every entry's content as z.unknown() — reaches the hook with ISO strings where it declares Dates, and the hook's failure is best-effort and swallowed. Parsing at the one door means no caller can be the one that forgets; a caller that already parsed pays an idempotent second pass.

So real-time sync is one subscription: after a successful write it emits on resourceEventEmitter and onSaveResourceContent streams { content, contentVersion, id } to the owner's other devices (the emitting device is filtered out, same as the messaging emitters). Subscribers adopt both the content and the contentVersion, so a remote write keeps their next save from being rejected as stale. TodoList wires this up client-side (useTodoListSubscribables → storeSaveResourceContent), making every item table operation live; other types can reuse the same subscription as needed. A type's subscription is registered in ResourceSubscribablesMap and run by the blade shell for as long as the resource is open, never by a blade — content is read once per open resource, so a subscription that ended with its blade would leave the next blade rendering what it last heard.

The type's after-save hook is registered in ResourceAfterSaveContentMap, keyed by ResourceType, rather than handed to the procedure factory — a hook reachable from only one of the paths that write content is the failure above. It receives the prior content (read before the write overwrites it, undefined on a first write) so it can diff, and is fire-and-forget and best-effort: it must never fail or delay the write. TodoList registers due reminders there.

The factory also accepts an optional publish-time content-transform hook, transformPublishedContent — see publishing. What a type declares live rather than frozen is not a hook but a declaration the snapshot mechanism owns — see resource snapshots.

resource.readResource answers with the row plus its publication (the resource_publications row, or null when there is none) — see publishing.

Ownership middleware: getOwnerProcedure(type, schema, resourceIdKey, isDeletedOnly = false) in server/trpc/procedure/resource/, querying resources and exposing ctx.resource; a typeless overload (type: undefined) backs the cross-type resource.readResource. isDeletedOnly inverts which rows resolve, so the recycle bin's purgeResource reaches only soft-deleted resources and every other procedure only live ones. The batch restoreResources, like deleteResources, scopes its own where to the owner and the bin instead.

Router topology

Router-per-type plus one cross-type router:

RouterContents
resourceeverything that must work without knowing the type — the explorer's reads and counts (sharing one filter schema so list and total stay in lockstep), favourites, activity, publish history, duplicate, and the recycle-bin lifecycle
sheet, todoList, dashboard, email, webpage, flowchart, notecreateResourceProcedures(type, …)
surveyfactory + type-specific procedures (public respondent responses)
programfactory + type-specific procedures (generateProgramParticipants, readProgramStatus)
blueprintfactory + type-specific procedures (captureBlueprint, deployBlueprint)

Router-per-type is load-bearing, not cosmetic: achievement triggerPaths key off the literal tRPC path ("flowchart.saveResourceContent"), and type-specific procedures need a home.

Client

  • Explorer (/resource-explorer) is an Azure-portal-style shell: a Home landing (search + quick-create tiles + recent resources), a full list at /resource-explorer/all, and a route-driven create flow (/resource-explorer/create gallery → /resource-explorer/create/[type] form). Home and /resource-explorer/all read through the shared useReadResources composable (resource.readResourcesCount + resource.readResources, different sort/limit/filter per surface). Resource pages live at /resource-explorer/[id]/[[blade]].
  • useResourceStore (apps/web/app/store/resource/index.ts) is the open resource: it loads the row (resource.readResource) with its publication, reads typed content ({type}.readResourceContent), and owns every write against that resource — saveContent (optimistic contentVersion), renameResource, updateResourceTags, deleteResource, duplicateResource and the capability actions (publishResource/unpublishResource, no-ops for non-publishable types). It is blade-scoped: the store is app-lifetime, this state is one open resource's, so the page clears it on unmount, keyed by the id it opened because a keyed page swap mounts the next resource's page first.
  • A type's content store composes the resource store rather than loading its own copy — useSheetStore, useNoteStore, useDashboardStore and the rest hold only their own parsed content and reach the row through readResource/readContent/saveContent. The store cannot be generic over ResourceType, so the type parameter moved to readContent<ResourceType.Sheet>(applyContent), the one member whose content shape depends on it — it hands the content to applyContent only while the read is the latest one issued for the opening still open, so a read landing after a switch or a restore's re-read reaches no store; everything else about a resource row is identical for every type. One row, one publication, one loading flag, so a rename in the toolbar is the name the editor sees.
  • Content is read once per open resource, not once per blade. The page reads the row before any blade mounts, and a content store's loadContent reads the blob only while checkIsContentRead() says the store holds none for this resource. Every blade of a type renders the one content, so switching blades renders from the store with no round trip; what changes the content after that reaches the store without a re-read — its own saves, the type's live subscription, and a restore's ResourceContentHookMap.Reload. Closing the resource resets it, so reopening reads afresh. Every content store is built on createContentData, which is where the guard and the Reload re-read live. A read that lands after another opening is dropped whole — another resource, or the same one closed and reopened, whose id alone cannot tell the two apart. Each opening carries its own token (getOpening), replaced when the row read brings a different resource and when the page clears it, but not by a refresh of the one open: readContent hands the content over and stamps the store as read only while the opening that issued it is still the open one and no later read of it was issued, and a write issued before an await goes through getContentWriter, bound to the opening it was issued under, so the survey store's saves adopt what lands, and build the next save on a pending one, only for that opening, so a late read never shows another opening's document under the contentVersion the blade holds now.
  • save skips content identical to what was last persisted, compared as JSON, so an editor whose autosave fires per frame does not bump contentVersion for a document nobody changed. Nor does it write content that was never read for the open resource — a store still holding the previous resource's document, or its own empty default. Every content store seeds that comparison with setPersistedContent after hydrating, and nothing may stamp the content on the way in — a store that refreshed the content's own updatedAt before saving would make every comparison differ, and the modified time the explorer reads is the resources row's, which the server bumps per accepted write.
  • Resource pages are auth-gated. There is no unauthenticated/localStorage editing path — one persistence mechanism, not two.

Opening a resource reads the row once and the blob once; a blade switch after that is a render from the store, and only a restore or closing the resource makes the next blade read again:

sequenceDiagram
  participant Page as Resource page
  participant Shell as ResourceExplorer
  participant Blade as Blade (under Suspense)
  participant Content as content store (createContentData)
  participant Resource as useResourceStore
  participant Server

  Page->>Resource: readResource()
  Resource->>Server: resource.readResource
  Page->>Shell: mount for the resource's lifetime
  Shell->>Server: the type's subscription (ResourceSubscribablesMap)
  Blade->>Content: await loadContent()
  Content->>Resource: checkIsContentRead() — false
  Content->>Server: {type}.readResourceContent
  Note over Blade,Content: switch blade
  Blade->>Content: await loadContent()
  Content->>Resource: checkIsContentRead() — true, no request
  Server-->>Content: another device's save, adopted through the subscription
  Note over Resource,Content: restore — reloadResourceContent re-reads the row, then Reload re-reads the blob
  Page->>Resource: clearResource(id) on unmount — the next open reads afresh

Key files

FileRole
packages/db-schema/src/schema/resource/resourcesInResource.tsidentity table
packages/db-schema/src/schema/resource/resourcePublicationsInResource.tspublish state table
apps/web/shared/services/resource/ResourceDefinitionMap.tstype definitions + capability declarations
apps/web/shared/models/resource/CapabilityResourceType.tsderived capability unions
apps/web/shared/services/resource/getFilesDirectoryName.tsthe {id}/files path convention
apps/web/shared/services/resource/checkHasCapability.tsruntime capability guard
apps/web/server/trpc/procedure/resource/createResourceProcedures.tsthe procedure factory
apps/web/server/trpc/procedure/resource/getOwnerProcedure.tsownership middleware
apps/web/app/store/resource/index.tsthe open resource — state and every write
apps/web/app/services/resource/ResourceBladeDefinitionMap.tstype-specific blades
apps/web/app/services/resource/ViewComponentMap.tspublic view renderers (Publishable)
apps/web/app/services/resource/PortableFormatMap.tsimport/export formats (Portable)

Details

Command palette

Keyboard shortcuts