Skip to main content

Storage & Buckets

monodit provides two backend-backed storage systems inside the sandbox:

  • Document storage via ctx.store for shared JSON data
  • Asset storage via ctx.assets for files stored in Cloudflare R2

In addition, the bridge exposes local cache via ctx.cache for browser-only per-instance data such as response snapshots or favicon caches.

Both systems are organized around the same host concept: a bucket.

This page explains how buckets work, how widgets opt into host-managed bucket sharing, and how quotas are enforced.

For the raw bridge surface, see Bridge API Reference.


The Mental Model

Every storage-capable widget instance starts in an isolated state.

That means:

  • a document widget defaults to a bucket derived from its instanceId
  • an asset widget also defaults to a bucket derived from its instanceId

Users can later connect multiple compatible widgets to the same bucket through the host UI.

In practice, that allows patterns like:

  • two note widgets sharing one document
  • multiple gallery/image widgets sharing one asset bucket
  • switching a widget from a private bucket to a shared team bucket

Storage Backends

d1-json

The d1-json backend powers ctx.store.

Characteristics:

  • one logical JSON document per bucket
  • local-first cache in IndexedDB
  • real-time cross-widget updates on the current page
  • quota tracked by payload byte size

Typical use cases:

  • notes
  • todo lists
  • counters
  • small structured datasets

r2-assets

The r2-assets backend powers ctx.assets.

Characteristics:

  • binary file storage in Cloudflare R2
  • metadata indexed in D1
  • bucket-aware listing and deletion
  • quota tracked by object byte size

Typical use cases:

  • image galleries
  • profile pictures
  • attachments
  • media libraries

Storage Contracts

If you want the host UI to understand whether a widget can connect to a bucket, your widget must declare a storage contract in its manifest and mark the manifest as "widgetType": "dynamic".

Example:

{
"widgetType": "dynamic",
"storage": {
"bindings": [
{
"key": "primary",
"backend": "d1-json",
"schemaId": "notes.plaintext",
"schemaVersion": 1,
"shareable": true
}
]
}
}

For asset widgets:

{
"widgetType": "dynamic",
"storage": {
"bindings": [
{
"key": "assets",
"backend": "r2-assets",
"schemaId": "gallery.assets",
"schemaVersion": 1,
"configKey": "assetBucketId",
"shareable": true
}
]
}
}

Binding Fields

FieldRequiredDescription
keyYesLogical binding name used by the widget author
backendYesd1-json or r2-assets
schemaIdYesLogical schema identifier used for compatibility
schemaVersionYesInteger version for the schema
configKeyNoConfig field used by the host to store the connected bucket ID
labelNoHuman-readable label for settings UI
shareableNoWhether the binding is intended to be shared across widgets

Why Contracts Matter

Without a storage contract, the host cannot safely answer:

  • can this widget connect to that bucket?
  • should this bucket appear in the picker?
  • is this a document bucket or an asset bucket?

With a contract, the host can validate compatibility using:

  • backend
  • schema ID
  • schema version

Two widgets are considered compatible only when those values match.

In the current widget manifest rules, a storage contract and "widgetType": "dynamic" must travel together. A basic widget cannot declare bucket storage.


Bucket Resolution

Document Buckets

For d1-json bindings:

  1. if the widget calls ctx.store.get("explicit-name"), that explicit namespace is used
  2. otherwise the bridge checks ctx.config.storageNamespace
  3. if that is empty, it falls back to ctx.instanceId

Asset Buckets

For r2-assets bindings:

  1. the host checks the binding's configKey such as assetBucketId
  2. if that config field is empty, it falls back to ctx.instanceId

This is why new widgets appear as isolated by default, but can be re-linked later.


ctx.store: Shared JSON Documents

Document buckets are ideal when the entire state can be represented as one reasonably small JSON value.

await ctx.store.set({ text: "Hello world" });
const value = await ctx.store.get();

Best Practices

  • Treat one bucket as one logical document.
  • Keep payloads small and coherent.
  • Subscribe to updates and re-render from the new value.
  • Re-bind if your widget depends on storageNamespace and that config changes.
  • Treat the server bucket as the source of truth and the local browser cache as a fast mirror.
  • Assume subscription callbacks may fire more than once for the same logical edit because the host can emit:
    • an optimistic local update
    • a later confirmation after the server write completes
    • a revalidation update after checking server metadata

Runtime Model

For d1-json buckets, the host uses a validation cache:

  • the current document is cached in IndexedDB per namespace
  • ctx.store.get() returns the cached snapshot first when available
  • the host then checks lightweight server metadata
  • the full document is only fetched again when the server indicates a newer value
  • active document buckets are re-checked when the window regains focus

This keeps server reads low while still allowing another browser or device to catch up without re-mounting the widget.

Good:

ctx.store.subscribe(null, renderDocument);

Good for text editors:

let pendingRemote = null;
let draftVersion = 0;
let lastSavedVersion = 0;

function hasDirtyDraft() {
return draftVersion !== lastSavedVersion;
}

ctx.store.subscribe(null, (value) => {
if (document.activeElement === textarea && hasDirtyDraft()) {
pendingRemote = value;
return;
}

renderDocument(value);
});

Risky:

  • storing huge append-only logs
  • writing many unrelated datasets into one document
  • assuming the namespace never changes
  • blindly assigning input.value = ... on every subscription callback while the user is typing

ctx.assets: Shared File Buckets

Asset buckets are ideal when the shared state is a set of files instead of one JSON blob.

const url = await ctx.assets.upload(file);
const assets = await ctx.assets.list();
await ctx.assets.delete(assets[0].objectKey);

Best Practices

  • Treat ctx.assets.list() as the source of truth for bucket contents.
  • If your widget listens for bucket changes, use ctx.assets.subscribe(...).
  • Re-bind if your widget depends on an asset bucket config field and that config changes.

Example:

let unsubscribe = null;

function bindBucket() {
if (unsubscribe) unsubscribe();
unsubscribe = ctx.assets.subscribe(loadAssets);
}

ctx.onConfigChange(() => {
bindBucket();
loadAssets();
});

Storage Topology UI

The host exposes a Storage Topology view in account settings.

That UI can:

  • show all known buckets
  • show connected widgets
  • show per-bucket usage
  • allow a widget to move to a different compatible bucket
  • delete a bucket

Compatibility is driven by the widget's storage contract.

What "Isolated" Means

If the UI shows a widget as Isolated, it means the widget is still using its own default bucket derived from its instanceId.

It is not yet sharing storage with any other widget.


Quotas

Storage is tracked per user across both backends.

Current limits:

  • Free: 100 MB logical limit, but storage APIs are gated off
  • Pro: 5 GB base limit
  • Add-ons: extra storage in 5 GB increments

Quota includes:

  • D1 document payload size for ctx.store
  • R2 object byte size for ctx.assets

When a write exceeds quota, the request fails with QUOTA_EXCEEDED.

try {
await ctx.assets.upload(file);
} catch (err) {
if (err.message === "QUOTA_EXCEEDED") {
alert("Storage quota exceeded.");
}
}

Deletion Semantics

Deleting a Document Bucket

When a d1-json bucket is deleted:

  • the document is removed from D1
  • local cache is invalidated
  • connected widgets fall back to isolated buckets if the host disconnects them

Deleting an Asset Bucket

When an r2-assets bucket is deleted:

  • all indexed assets in that bucket are deleted from R2
  • asset metadata rows are removed from D1
  • quota is refunded
  • connected widgets fall back to isolated buckets if the host disconnects them

Patterns That Work Well

Shared Notes

  • backend: d1-json
  • schema: notes.plaintext
  • data shape: { text, updatedAt }

Shared Image Bucket

  • backend: r2-assets
  • schema: gallery.assets
  • source of truth: ctx.assets.list()

Mixed Widgets

Some widgets need both:

{
"storage": {
"bindings": [
{
"key": "content",
"backend": "d1-json",
"schemaId": "gallery.settings",
"schemaVersion": 1
},
{
"key": "assets",
"backend": "r2-assets",
"schemaId": "gallery.assets",
"schemaVersion": 1,
"configKey": "assetBucketId"
}
]
}
}

This allows one widget to store:

  • structured settings in D1
  • files in R2

Guidance for Dynamic Themes

Dynamic Themes can use the same bridge APIs, but they are not first-class participants in widget bucket topology UX.

That means:

  • ctx.store and ctx.assets are available
  • you can still use explicit namespaces/bucket IDs
  • but host-managed bucket compatibility UI is primarily designed for widgets

If a Dynamic Theme needs persistent shared state, keep the structure explicit and stable.


Summary

Choose the backend that matches the data:

  • ctx.storage for tiny private state
  • ctx.store for shared JSON documents
  • ctx.assets for shared files

If you want host-managed reconnection, compatibility checks, and topology UI, declare a storage contract in the widget manifest.