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.
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:
- Read the resolved contract.
- Implement the required
query()method. - Declare the operators and pagination modes that the backend executes.
- Return normalized provider documents.
- Add optional operations only when the backend has an efficient implementation.
- 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.
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:
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:
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
| Backend | Adapter responsibility |
|---|---|
| Convex | Translate filters, ordering, and cursors to indexed Convex queries. |
| Postgres | Translate the bounded plan to parameterized SQL and an opaque cursor. |
| Workers or Durable Objects | Use Worker bindings inside the adapter context. Keep binding names and object IDs private. |
| REST or GraphQL CMS | Translate 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.
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
ContentProviderintegration. - The exact packed artifact and dependency hashes are recorded with the conformance results.