@async/db

CMS Storage Patterns

CMS workflows are app code on top of @async/db primitives. The package should not know what draft, review, published, or unpublished mean.

App Code Boundary

export function createCms(db, { tenantId }) {
  return {
    async saveDraft(pageId, changes) {
      const tenant = await db.forks.open(tenantId);
      const draft = await tenant.branches.open('draft');
      return draft.collection('pages').patch(pageId, {
        ...changes,
        status: 'draft',
      });
    },

    async publish() {
      const tenant = await db.forks.open(tenantId);
      const draft = await tenant.branches.open('draft');
      const published = await tenant.branches.open('published');
      const pages = await draft.collection('pages').all();

      await published.collection('pages').replaceAll(
        pages
          .filter((page) => page.status === 'published')
          .map(({ id, slug, title, status, summary, bodyMarkdown }) => ({
            id,
            slug,
            title,
            status,
            summary,
            bodyMarkdown,
          })),
      );
    },
  };
}

@async/db owns forks, branches, snapshots, resources, operations, migrations, and routing. @async/db/json owns file/object storage, durability, encryption hooks, corruption checks, and layout.

Pattern 1: One JSON File Per Resource

Use this for simple content:

forks/tenant_acme/branches/draft/resources/pages.json
forks/tenant_acme/branches/published/resources/pages.json

The app filters draft records and writes the public result into the published branch.

Pattern 2: App-Owned Static JSON Outputs

When static hosting, S3/R2 reads, or page-level cache invalidation matter, keep that materialization in app code instead of configuring the JSON store layout:

const tenant = await db.forks.open(tenantId);
const publishedBranch = await tenant.branches.open('published');
const published = await publishedBranch.collection('pages').all();

for (const page of published) {
  await writePublicJson(`pages/${page.slug}.json`, page);
}

The package owns resource reads/writes and branch isolation. The app owns any extra files it wants to publish for CDN or public API access.

Pattern 3: JSON Files With SQLite Metadata Index

Use JSON files as documents and SQLite as an app-owned metadata index:

content/pages/home.json
content/pages/about.json
content-index.sqlite

The app writes the JSON document and updates SQLite metadata such as slug, status, published_at, and file_path. Queries use SQLite to find files, then read JSON documents.

Pattern 4: Draft JSON To Published Postgres

Keep editorial drafts as JSON, then materialize published records into Postgres:

await cms.publish({
  from: { branch: 'draft', store: 'json' },
  to: { branch: 'published', store: 'postgres' },
});

This is useful when the public site needs database indexes but editorial workflow still benefits from file-like JSON.

Pattern 5: Static JSON API Materialization

Publishing can write multiple public JSON outputs:

published/pages.json
published/pages/home.json
published/navigation.json
published/search-index.json
published/sitemap.json

That materialization is app-owned. The package provides resource reads/writes, branches, snapshots, and JSON storage helpers.