Skip to main content
Version: v0.25.0 (Latest)

Persistence and Offline Cache

The EDK has two unrelated storage stories for credential designs and they solve different problems. The server-side persistence modules back the design service with PostgreSQL or MySQL, so a multi-tenant service can store designs durably and read them with normal database semantics. The client-side cache wrapper sits in front of a remote design service and keeps a local copy of what has been read, so a wallet or verifier UI can keep rendering credentials when the network is unreachable. Most services need one or the other, not both.

Server-Side Persistence

Pick one of the persistence modules and add it to your service alongside lib-data-store-credential-design-impl. The repository implementations are bound through Metro DI and the rest of the EDK does not need to know which dialect is in use.

ModuleBackend
lib-data-store-credential-design-persistence-postgresqlPostgreSQL via SQLDelight
lib-data-store-credential-design-persistence-mysqlMySQL via SQLDelight

Inside the chosen module you get implementations of every repository the design service depends on:

  • CredentialDesignRepository, IssuerDesignRepository, VerifierDesignRepository for the three entity types
  • RenderVariantRepository for render variants
  • DerivedRenderHintsRepository for hints produced by the OCA mapper and other deriving sources
  • SourceSnapshotRepository for the raw payloads cached when a design is imported from an external source
  • DesignCurrentVersionRepository for the per-design current-version pointer described in Versioning

Both backends share the same SQLDelight schema with dialect-specific tweaks for JSON column types (PostgreSQL uses JSONB, MySQL uses JSON). The repository APIs are identical, so a service can move between dialects without code changes; only the dependency on the matching -persistence-{dialect} module changes.

The schema for one design row is a small set of indexed top-level columns (id, tenant_id, alias, hosting_mode, created_at, updated_at) plus a JSON column for the polymorphic parts (bindings, displays, claims, render variant ids). Lookups by binding key go through generated indexes; lookups inside claim arrays are not indexed and are done at the JSON level.

Tenant Isolation

Every repository method takes tenantId and filters by it at the SQL level. The IDK service interface takes tenantId as a method argument, so it stays explicit through to the database. In a multi-tenant deployment, the EDK database routing layer can route per-tenant requests to per-tenant databases or schemas; the credential design store does not have its own routing and relies on the shared platform mechanism.

Imports and Source Snapshots

When you call importExternalDesign, the service fetches the source URL through the SSRF-protected DesignExternalFetcher, stores the raw response as a SourceSnapshotRecord, runs the matching format mapper, and writes the resulting CredentialDesignRecord. The snapshot row keeps the body and any ETag/Last-Modified headers; refreshCredentialDesign uses them for conditional re-fetches, so unchanged sources do not redownload.

The snapshot is the audit answer for "what did the upstream metadata actually look like when this design was last imported?" Snapshot rows are kept until the design is deleted; there is no time-based cleanup.

Wiring Checklist

1. Add the EDK base module:           lib-data-store-credential-design-impl
2. Add one persistence module: lib-data-store-credential-design-persistence-postgresql
or lib-data-store-credential-design-persistence-mysql
3. Configure your DataSource (standard EDK pattern, see /edk/guides/persistence/...)
4. (Optional) REST adapter: lib-data-store-credential-design-rest-vdx
(registers CredentialDesignHttpAdapter at /api/v1/designs)

The Metro replaces mechanism swaps the IDK in-memory DefaultCredentialDesignService binding for the EDK DefaultVersionedCredentialDesignService binding automatically, with no explicit unbinding or wiring code.

Offline Cache

The cache wrapper solves a narrow problem: a client process needs to keep returning credential designs when the call to the design service fails. Typical case is a mobile wallet or a verifier UI rendering a credential while the network is down. It is not a performance cache; it does not save round-trips on the happy path.

Behaviour

CachingCredentialDesignService wraps two collaborators: a remote CredentialDesignService (usually the routed-command client that talks to the design service over HTTP RPC) and a local BlobService (filesystem, SQLite-backed, in-memory). It implements the same CredentialDesignService interface, so consumers do not see the difference.

For reads of an individual entity (getCredentialDesign, getIssuerDesign, getVerifierDesign, getRenderVariant, asset get, snapshot get), the order is:

  1. Call the remote.
  2. If remote succeeds: write the result to the local blob store as a side effect, return the remote result. The cache is updated; the remote answer wins.
  3. If remote fails: look up the local blob store. If a cached entry exists, return it. If not, return the remote failure.

For listing (listCredentialDesigns, findCredentialDesignByBinding, findCredentialDesignByBindingKey, listRenderVariants), there is no caching and no fallback. The remote is the only source. The list operations are pass-through. This is intentional: caching a list is meaningless when the list itself can change between calls and the cache cannot know.

For writes (create*, update*, import*, refresh*, asset upload), the order is: call the remote; on success, update the cache; return the remote result. The cache is consistent with the last successful write the client made through this wrapper, not with arbitrary changes that other clients may have applied to the remote.

For deletes, the same pattern: delete on the remote, then invalidate the cached entry on success.

What the Cache Cannot Do

  • It cannot serve a findCredentialDesignByBindingKey lookup when the remote is down, because that operation is not cached. A wallet that wants to render a credential by its VCT should first do the lookup while the network is up and remember the design id, then use getCredentialDesign(id) when offline. Calling resolveCredentialDesign while offline likewise fails (it is not a cached read path).
  • It cannot detect remote-side changes. A design that another client updated will not be reflected in the cache until this client makes a read against the remote successfully. There is no background invalidation.
  • It does not have a TTL. A cached entry stays until the next successful write through this wrapper or an explicit invalidation through the underlying blob store.

Wiring

val remote: CredentialDesignService = /* routed-command client to the design service */
val localBlobs: BlobService = /* FileSystemBlobService, SqliteBlobService, ... */

val designService: CredentialDesignService = CachingCredentialDesignService(
remote = remote,
localBlobService = localBlobs,
)

Inject the wrapper as your CredentialDesignService and consumers (renderers, OID4VCI clients, anything that holds the IDK interface) read through it transparently.

Sync Helper

CredentialDesignSyncService.syncAll(tenantId, filter) is a companion that pulls every design matching the filter from the remote and routes each one through the caching wrapper's getCredentialDesign(id). The effect is that every design in the filtered set is in the local blob store afterwards. Call this at app startup or before going offline to pre-warm the cache.

val syncService = CredentialDesignSyncService(
remote = remote,
cachingService = designService as CachingCredentialDesignService,
)
val synced = syncService.syncAll(tenantId).getOrElse { return }
// `synced` is the list of designs that were fetched. After this call, getCredentialDesign(id)
// for any of these ids will succeed even if the network later goes down.

The sync calls the remote for each design id; it does not currently do server-side delta detection. If you are syncing thousands of designs you will pay for one round-trip each.

Blob Storage Options

BlobService is the IDK abstraction. Pick the implementation that matches your platform:

BlobServiceUse case
InMemoryBlobServiceTests, short-lived process caches
FileSystemBlobServiceDesktop apps, server-side temporary caches
SqliteBlobServiceMobile wallets where the cache must survive restarts

The cache wrapper treats the blob store as opaque: it serialises each record to JSON, writes it under a path derived from (tenantId, entityType, id), and reads it back the same way. Any custom BlobService implementation works.

When You Need Both

A multi-tenant EDK service that hosts the design registry uses the server-side persistence. A wallet that talks to that service uses the offline cache in front of a routed-command client. The two ends of the same architecture: the cache is on the consuming side, the persistence is on the serving side, and the wire format between them is the routed CredentialDesignService interface.

A single process almost never needs both. If your service has direct database access through the persistence modules, do not put the cache in front of it; the cache is meant for callers that are at least one network hop away from the database.