Skip to main content

Data-source adapters

Build a bounded, framework-free adapter for a CMS or remote content store.

Use @lupinum/ginko-content/data-source when a CMS or remote store serves live content. The data source is the pure, framework-free read contract. Use bindContentProvider from @lupinum/ginko-content/provider only at the Nuxt/H3 edge to expose that source as a runtime ContentProvider.

Read the resolved contract

Run nuxt prepare before a CMS tool reads the application contract. The module generates .ginko/content-contract.json from the same resolved settings used by the content runtime. Do not edit or commit this file.

scripts/read-content-contract.ts
import { readResolvedContentContract } from '@lupinum/ginko-content/cms-contract/node'

const { contract, sha256 } = await readResolvedContentContract({
  root: process.cwd()
})

Use contract to configure CMS fields, localization, routes, references, and portable components. Store sha256 with a deployment or import receipt. Reject an operation when its expected hash does not match. Do not reconstruct the contract from content.config.ts and Nuxt options in another package.

Ownership boundary

Ginko owns the fixed-shape query types, bounds, result envelopes, route facts, cache-hint validation, public projection, and the Ginko portability codec. The adapter owns its verified context and backend behavior. Persistence, authorization, byte streaming, and retry policy stay in the adapter; do not move those policies into Ginko or reconstruct portable document and manifest shapes locally.

Create a verified context from the incoming request before calling the data source. It should contain only facts that the adapter has authenticated and authorized, such as tenant and actor identifiers. Never pass raw headers, cookies, bearer tokens, or a caller-supplied tenant through as verified facts.

Every operation is bounded and fixed-shape. Honor control.signal and control.deadlineAt, advertise only operations the backend implements, and never fetch an unbounded roster to emulate pagination. Return the exact ContentDataSourceResult envelope and use false when no cache hint exists.

The adapter flow is:

  1. Read the resolved contract.
  2. Implement the required query() method.
  3. Declare the operators and pagination modes that the backend executes.
  4. Return normalized provider documents.
  5. Add optional operations only when the backend has an efficient implementation.
  6. Run source conformance.

If a CMS producer is deployed separately from the Nuxt application, wrap every result with createCmsProviderWireEnvelope() from cms-contract. The consumer parsers require the matching CMS_PROVIDER_WIRE_PROTOCOL; package/component skew then fails clearly before Ginko interprets backend data.

Complete minimal adapter

This exact example is typechecked by the repository. Its public imports are also exercised by the packed-consumer gates.

data-source-adapter.ts
import type { H3Event } from 'h3'
import {
  CONTENT_DATA_SOURCE_LIMITS,
  createContentDataSourceCacheHint,
  createContentDataSourceError,
  type BoundedContentProviderQuery,
  type ContentDataSource,
  type ContentDataSourceControl,
} from '@lupinum/ginko-content/data-source'
import { bindContentProvider } from '@lupinum/ginko-content/provider'

const cache = createContentDataSourceCacheHint({ tags: ['content:docs'], maxAge: 60 })
const unsupported = createContentDataSourceError('QUERY_UNSUPPORTED')
void [cache, unsupported]

interface VerifiedContext {
  tenantId: string
  actorId: string
}

type QueryResult = Awaited<ReturnType<ContentDataSource<VerifiedContext>['query']>>

interface CmsBackend {
  query(input: {
    context: VerifiedContext
    query: BoundedContentProviderQuery
    control: ContentDataSourceControl
  }): Promise<QueryResult>
}

export function createCmsContentProvider(args: {
  backend: CmsBackend
  createVerifiedContext(event: H3Event): Promise<VerifiedContext>
}) {
  const source = {
    name: 'cms',
    capabilities: {
      protocol: 'ginko-content-data-source/v1',
      query: {
        operators: ['$eq'],
        pagination: ['offset'],
        maxPageSize: CONTENT_DATA_SOURCE_LIMITS.maxQueryPageSize,
      },
    },
    query(context, query, control) {
      return args.backend.query({ context, query, control })
    },
  } satisfies ContentDataSource<VerifiedContext>

  return bindContentProvider({
    source,
    createContext: args.createVerifiedContext,
  })
}

The backend method receives the already bounded public query and the verified context. It should translate that query directly to one backend request. Add navigation, surroundings, search, siteData, or cursor-paged routes only when the backend implements the corresponding fixed result shape.

Use the cache-hint constructor when one operation has cache facts:

server/cache-hint.ts
import { createContentDataSourceCacheHint } from '@lupinum/ginko-content/data-source'

export const cache = createContentDataSourceCacheHint({
  tags: ['collection:docs'],
  maxAge: 60,
  swr: 300
})

Ginko validates one hint and owns all merge behavior. Do not merge tags, paths, or ETags in the adapter.

Test with the in-memory source

Use the package fixture for tests and development tools that do not need a real database:

test/content-source.ts
import {
  createDefaultProviderFixture,
  createFixtureContentDataSource
} from '@lupinum/ginko-content/testing/provider-fixture'

export const source = createFixtureContentDataSource(
  createDefaultProviderFixture()
)

This fixture is test infrastructure. It is not a production storage adapter.

Translate the query at one boundary

BackendAdapter responsibility
ConvexTranslate filters, ordering, and cursors to indexed Convex queries.
PostgresTranslate the bounded plan to parameterized SQL and an opaque cursor.
Workers or Durable ObjectsUse Worker bindings inside the adapter context. Keep binding names and object IDs private.
REST or GraphQL CMSTranslate one bounded operation to the remote API. Do not expose its query language through Ginko types.

The data-source result is identical for every backend. Database table names, Convex function references, SQL, bindings, and remote query documents do not cross the adapter boundary.

Portable reads and writes

The data-source contract is read-only. When an adapter imports or exports content, use @lupinum/ginko-content/portability and @lupinum/ginko-content/portability/node; do not define another document, frontmatter, manifest, identity, or hash model. Keep authorization, transactions, receipts, cleanup, asset byte streaming, and retry semantics in the adapter's operational layer.

CMS and portability MDC parsing uses Ginko's fixed portable baseline. Its syntax and normalized AST change only with the package's versioned contract; it does not execute an application's Nuxt Markdown plugin configuration. Filesystem ingestion is intentionally different: it uses the build-time markdown.plugins profile from that application. If portable content requires an enriched construct, store the portable baseline form and apply the application-specific presentation at the filesystem or Vue boundary.

Find and remap stored media

Use Content's asset helpers when an application stores media IDs in Markdown or MDC. The helpers read native images and the media source property declared by each component policy. Text, links, and code examples are not media references.

app/remap-media.ts
import type { PortableComponentPolicy } from '@lupinum/ginko-content/cms-contract'
import {
  collectStoredMdcAssetReferences,
  rewriteStoredMdcAssetReferencesForStorage
} from '@lupinum/ginko-content/portability'

export async function remapMedia(
  source: string,
  policy: PortableComponentPolicy,
  replacements: ReadonlyMap<string, string>
) {
  const referencedIds = await collectStoredMdcAssetReferences(source, policy)
  const rewritten = await rewriteStoredMdcAssetReferencesForStorage(
    source,
    policy,
    id => replacements.get(id) ?? id
  )
  return { referencedIds, source: rewritten }
}

Collection returns one ID per occurrence in document order. Both helpers reject documents outside the supplied policy. Handle that error before deleting any assets; a rejected document does not mean that it has no references.

The rewrite callback can be asynchronous and must return a valid stored ID. If no ID changes, the source remains byte-for-byte identical. Otherwise, Content serializes and validates the changed document. The existing rewriteStoredMdcAssetReferences helper instead converts stored IDs to portable asset paths or public URLs for export.

Evidence levels

Level 1 is protocol conformance. Use runContentDataSourceContractSuite from @lupinum/ginko-content/testing/data-source-contract with an adapter-owned verified context. Its found, missing, list, count, two-page cursor, and optional operation probes execute through the real binder. Offset adapters must provide a count probe. Every implemented optional operation must provide a discriminating probe. Keep runContentDataSourceContract for a focused single-query probe. Core's binder contract owns adversarial response, cache, cancellation, deadline, JSON-purity, and route-limit tests. Adapters must not duplicate those invariants. A passing Level 1 result proves the public protocol and bounds only.

routes() returns one stable snapshot for the complete logical enumeration and its exact collection/locale scope, not one snapshot per backend partition. That snapshot must remain unchanged across all cursor pages. Cursors are opaque and must be bound to the snapshot, ordering, collections, and locales. Ginko rejects changing snapshots, repeated cursors, empty continuation pages, and non-progress with ROUTE_ENUMERATION_INVALID.

Route limits are protocol rejection ceilings, not throughput promises: 100,000 records, 64 KiB per serialized record, 32 MiB for the serialized enumeration, 16 sitemap images per route, and 2 KiB per image location. Publish separate production benchmarks for any supported operating scale.

Adapters may throw createContentDataSourceError('QUERY_CURSOR_INVALID') for a stale or scope-invalid query cursor. Throw createContentDataSourceError('QUERY_UNSUPPORTED') when the bounded plan cannot be executed. Throw createContentDataSourceError('BACKEND_FAILURE') for a sanitized backend failure. Do not include backend messages or details. The public boundary maps query errors to 400 and backend failures to 502. Binder deadlines map to 504. Invalid provider responses map to 502.

Import createContentDataSourceError and the ContentDataSourceErrorCode type from @lupinum/ginko-content/data-source. The code union is the stable contract; backend exception classes, messages, and details are not.

Level 2 is adapter-owned operational evidence. It must separately prove the backend's authorization matrix, transaction or receipt behavior, retries, crash recovery, scoped cleanup, concurrency, asset streaming, and deployment limits. Never describe an adapter as operationally certified from Level 1 alone. Release evidence must name the adapter artifact hash and both sets of results.

When runtime delivery is required, integrate the bound ContentProvider in content.config.ts. Portability-only tools do not need a runtime provider.

Test the packed artifact

Pack and install the adapter with the exact Ginko tarball in a fresh consumer, without workspace resolution. Reject workspace:, link:, sibling-source, registry fallback, peer warnings, and duplicate physical package resolutions. Run typecheck, production build, Level 1 conformance, and the adapter-owned Level 2 scenarios against those installed bytes.

Production checklist

  • The context is authenticated, authorized, adapter-owned, and request-scoped.
  • Every advertised operation has positive and negative probes.
  • Query, search, navigation, route, site-data, cache, error, and deadline bounds are enforced at the backend edge.
  • Abort signals cancel backend work and no credential appears in cache keys, paths, asset URLs, results, or errors.
  • Portable reads and writes use the Ginko portability codec and canonical hash.
  • Persistence, authorization, streaming, receipts, cleanup, and retries have adapter-owned Level 2 evidence.
  • Runtime delivery uses the separate ContentProvider integration.
  • The exact packed artifact and dependency hashes are recorded with the conformance results.