Repository: google/exposure-notifications-server
Pull request #407 (2020-05-21 20:49:08)

Separate db and model for Exposure/Publish

google-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/407#" title="Author self-approved">crwilcox</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/master/OWNERS)~~ [crwilcox]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/internal/cleanup/cleanup.go b/internal/cleanup/cleanup.go
index 154d1a5..d242497 100644
--- a/internal/cleanup/cleanup.go
+++ b/internal/cleanup/cleanup.go
@@ -21,8 +21,8 @@ import (
 	"net/http"
 	"time"
 
-	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/database"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
@@ -43,14 +43,14 @@ func NewExposureHandler(config *Config, env *serverenv.ServerEnv) (http.Handler,
 	return &exposureCleanupHandler{
 		config:   config,
 		env:      env,
-		database: env.Database(),
+		database: publishdb.New(env.Database()),
 	}, nil
 }
 
 type exposureCleanupHandler struct {
 	config   *Config
 	env      *serverenv.ServerEnv
-	database *coredb.DB
+	database *publishdb.PublishDB
 }
 
 func (h *exposureCleanupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/export/batcher.go b/internal/export/batcher.go
index ff9f749..00d77e6 100644
--- a/internal/export/batcher.go
+++ b/internal/export/batcher.go
@@ -25,6 +25,8 @@ import (
 	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/database"
 	"github.com/google/exposure-notifications-server/internal/export/model"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/logging"
 )
 
@@ -140,7 +142,7 @@ var sanityDate = time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)
 func makeBatchRanges(period time.Duration, latestEnd, now time.Time, truncateWindow time.Duration) []batchRange {
 
 	// Compute the end of the exposure publish window; we don't want any batches with an end date greater than this time.
-	publishEnd := coredb.TruncateWindow(now, truncateWindow)
+	publishEnd := publishmodel.TruncateWindow(now, truncateWindow)
 
 	// Special case: if there have not been batches before, return only a single one.
 	// We use sanityDate here because the loop below will happily create batch ranges
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 4c7ad61..c3c7fc3 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -23,8 +23,8 @@ import (
 	"fmt"
 	"sort"
 
-	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/model"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 
 	"github.com/google/exposure-notifications-server/internal/pb/export"
 
@@ -46,7 +46,7 @@ type ExportSigners struct {
 }
 
 // MarshalExportFile converts the inputs into an encoded byte array.
-func MarshalExportFile(eb *model.ExportBatch, exposures []*database.Exposure, batchNum, batchSize int, signers []ExportSigners) ([]byte, error) {
+func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum, batchSize int, signers []ExportSigners) ([]byte, error) {
 	// create main exposure key export binary
 	expContents, err := marshalContents(eb, exposures, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
@@ -84,7 +84,7 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*database.Exposure, ba
 	return buf.Bytes(), nil
 }
 
-func marshalContents(eb *model.ExportBatch, exposures []*database.Exposure, batchNum int32, batchSize int32, signers []ExportSigners) ([]byte, error) {
+func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum int32, batchSize int32, signers []ExportSigners) ([]byte, error) {
 	exportBytes := []byte("EK Export v1    ")
 	if len(exportBytes) != fixedHeaderWidth {
 		return nil, fmt.Errorf("incorrect header length: %d", len(exportBytes))
diff --git a/internal/export/server.go b/internal/export/server.go
index c914d12..e759787 100644
--- a/internal/export/server.go
+++ b/internal/export/server.go
@@ -17,7 +17,10 @@ package export
 import (
 	"fmt"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 )
 
@@ -35,15 +38,19 @@ func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
 	}
 
 	return &Server{
-		db:     env.Database(),
-		config: config,
-		env:    env,
+		db:        env.Database(),
+		exportdb:  database.New(env.Database()),
+		publishdb: publishdb.New(env.Database()),
+		config:    config,
+		env:       env,
 	}, nil
 }
 
 // Server hosts end points to manage export batches.
 type Server struct {
-	db     *database.DB
-	config *Config
-	env    *serverenv.ServerEnv
+	db        *coredb.DB
+	exportdb  *database.ExportDB
+	publishdb *publishdb.PublishDB
+	config    *Config
+	env       *serverenv.ServerEnv
 }
diff --git a/internal/export/worker.go b/internal/export/worker.go
index bfda681..e10751a 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -26,7 +26,11 @@ import (
 
 	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/database"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+
 	"github.com/google/exposure-notifications-server/internal/export/model"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/util"
 )
@@ -86,9 +90,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	logger := logging.FromContext(ctx)
 	logger.Infof("Processing export batch %d (root: %q, region: %s), max records per file %d", eb.BatchID, eb.FilenameRoot, eb.Region, s.config.MaxRecords)
 
-	exportDB := database.New(s.db)
-
-	criteria := coredb.IterateExposuresCriteria{
+	criteria := publishdb.IterateExposuresCriteria{
 		SinceTimestamp:      eb.StartTimestamp,
 		UntilTimestamp:      eb.EndTimestamp,
 		IncludeRegions:      []string{eb.Region},
@@ -98,10 +100,10 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	// Build up groups of exposures in memory. We need to use memory so we can determine the
 	// total number of groups (which is embedded in each export file). This technique avoids
 	// SELECT COUNT which would lock the database slowing new uploads.
-	var groups [][]*coredb.Exposure
-	var exposures []*coredb.Exposure
+	var groups [][]*publishmodel.Exposure
+	var exposures []*publishmodel.Exposure
 
-	_, err := s.db.IterateExposures(ctx, criteria, func(exp *coredb.Exposure) error {
+	_, err := s.publishdb.IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
 		exposures = append(exposures, exp)
 		if len(exposures) == s.config.MaxRecords {
 			groups = append(groups, exposures)
@@ -128,7 +130,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	}
 
 	// Load the non-expired signature infos associated with this export batch.
-	sigInfos, err := exportDB.LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
+	sigInfos, err := s.exportdb.LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
 	if err != nil {
 		return fmt.Errorf("error loading signature info for batch %d, %w", eb.BatchID, err)
 	}
@@ -166,7 +168,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	}
 
 	// Write the files records in database and complete the batch.
-	if err := exportDB.FinalizeBatch(ctx, eb, objectNames, batchSize); err != nil {
+	if err := s.exportdb.FinalizeBatch(ctx, eb, objectNames, batchSize); err != nil {
 		return fmt.Errorf("completing batch: %w", err)
 	}
 	logger.Infof("Batch %d completed", eb.BatchID)
@@ -174,7 +176,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 }
 
 type createFileInfo struct {
-	exposures      []*coredb.Exposure
+	exposures      []*publishmodel.Exposure
 	exportBatch    *model.ExportBatch
 	signatureInfos []*model.SignatureInfo
 	batchNum       int
@@ -298,7 +300,7 @@ func randomInt(min, max int) (int, error) {
 	return int(n.Int64()) + min, nil
 }
 
-func ensureMinNumExposures(exposures []*coredb.Exposure, region string, minLength, jitter int) ([]*coredb.Exposure, error) {
+func ensureMinNumExposures(exposures []*publishmodel.Exposure, region string, minLength, jitter int) ([]*publishmodel.Exposure, error) {
 	if len(exposures) == 0 {
 		return exposures, nil
 	}
@@ -310,7 +312,7 @@ func ensureMinNumExposures(exposures []*coredb.Exposure, region string, minLengt
 		// Pieces needed are
 		// (1) exposure key, (2) interval number, (3) transmission risk
 		// Exposure key is 16 random bytes.
-		eKey := make([]byte, coredb.KeyLength)
+		eKey := make([]byte, publishmodel.KeyLength)
 		_, err := rand.Read(eKey)
 		if err != nil {
 			return nil, fmt.Errorf("rand.Read: %w", err)
@@ -336,13 +338,13 @@ func ensureMinNumExposures(exposures []*coredb.Exposure, region string, minLengt
 		}
 		intervalCount := exposures[fromIdx].IntervalCount
 
-		ek := &coredb.Exposure{
+		ek := &publishmodel.Exposure{
 			ExposureKey:      eKey,
 			TransmissionRisk: transmissionRisk,
 			Regions:          []string{region},
 			IntervalNumber:   intervalNumber,
 			IntervalCount:    intervalCount,
-			// The rest of the coredb.Exposure fields are not used in the export file.
+			// The rest of the publishmodel.Exposure fields are not used in the export file.
 		}
 		exposures = append(exposures, ek)
 	}
diff --git a/internal/federationin/federationin.go b/internal/federationin/federationin.go
index 0faee37..1eb3163 100644
--- a/internal/federationin/federationin.go
+++ b/internal/federationin/federationin.go
@@ -27,10 +27,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/pb"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 
 	"google.golang.org/api/idtoken"
@@ -49,8 +51,8 @@ var (
 
 type (
 	fetchFn               func(context.Context, *pb.FederationFetchRequest, ...grpc.CallOption) (*pb.FederationFetchResponse, error)
-	insertExposuresFn     func(context.Context, []*database.Exposure) error
-	startFederationSyncFn func(context.Context, *database.FederationInQuery, time.Time) (int64, database.FinalizeSyncFn, error)
+	insertExposuresFn     func(context.Context, []*model.Exposure) error
+	startFederationSyncFn func(context.Context, *coredb.FederationInQuery, time.Time) (int64, coredb.FinalizeSyncFn, error)
 )
 
 type pullDependencies struct {
@@ -63,16 +65,18 @@ type pullDependencies struct {
 // federation results for a single federation query.
 func NewHandler(env *serverenv.ServerEnv, config *Config) http.Handler {
 	return &handler{
-		env:    env,
-		db:     env.Database(),
-		config: config,
+		env:       env,
+		db:        env.Database(),
+		publishdb: database.New(env.Database()),
+		config:    config,
 	}
 }
 
 type handler struct {
-	env    *serverenv.ServerEnv
-	db     *database.DB
-	config *Config
+	env       *serverenv.ServerEnv
+	db        *coredb.DB
+	publishdb *database.PublishDB
+	config    *Config
 }
 
 func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -102,7 +106,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	lock := "query_" + queryID
 	unlockFn, err := h.db.Lock(ctx, lock, h.config.Timeout)
 	if err != nil {
-		if errors.Is(err, database.ErrAlreadyLocked) {
+		if errors.Is(err, coredb.ErrAlreadyLocked) {
 			metrics.WriteInt("federation-pull-lock-contention", true, 1)
 			msg := fmt.Sprintf("Lock %s already in use. No work will be performed.", lock)
 			logger.Infof(msg)
@@ -116,7 +120,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	query, err := h.db.GetFederationInQuery(ctx, queryID)
 	if err != nil {
-		if errors.Is(err, database.ErrNotFound) {
+		if errors.Is(err, coredb.ErrNotFound) {
 			badRequestf(ctx, w, "unknown %s", queryParam)
 			return
 		}
@@ -170,7 +174,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	deps := pullDependencies{
 		fetch:               client.Fetch,
-		insertExposures:     h.db.InsertExposures,
+		insertExposures:     h.publishdb.InsertExposures,
 		startFederationSync: h.db.StartFederationInSync,
 	}
 	batchStart := time.Now()
@@ -184,7 +188,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies, q *database.FederationInQuery, batchStart time.Time, truncateWindow time.Duration) error {
+func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies, q *coredb.FederationInQuery, batchStart time.Time, truncateWindow time.Duration) error {
 	logger := logging.FromContext(ctx)
 	logger.Infof("Processing query %q", q.QueryID)
 
@@ -205,7 +209,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 		logger.Infof("Inserted %d keys", total)
 	}()
 
-	createdAt := database.TruncateWindow(batchStart, truncateWindow)
+	createdAt := model.TruncateWindow(batchStart, truncateWindow)
 	partial := true
 	for partial {
 
@@ -222,7 +226,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 		}
 
 		// Loop through the result set, storing in database.
-		var exposures []*database.Exposure
+		var exposures []*model.Exposure
 		for _, ctr := range response.Response {
 
 			var upperRegions []string
@@ -234,12 +238,12 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 			for _, cti := range ctr.ContactTracingInfo {
 				for _, key := range cti.ExposureKeys {
 
-					if cti.TransmissionRisk < database.MinTransmissionRisk || cti.TransmissionRisk > database.MaxTransmissionRisk {
+					if cti.TransmissionRisk < model.MinTransmissionRisk || cti.TransmissionRisk > model.MaxTransmissionRisk {
 						logger.Errorf("invalid transmission risk %v - dropping record.", cti.TransmissionRisk)
 						continue
 					}
 
-					exposures = append(exposures, &database.Exposure{
+					exposures = append(exposures, &model.Exposure{
 						TransmissionRisk: int(cti.TransmissionRisk),
 						ExposureKey:      key.ExposureKey,
 						Regions:          upperRegions,
diff --git a/internal/federationout/federationout.go b/internal/federationout/federationout.go
index 2170e74..b4c8817 100644
--- a/internal/federationout/federationout.go
+++ b/internal/federationout/federationout.go
@@ -23,9 +23,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/pb"
+
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"google.golang.org/api/idtoken"
 	"google.golang.org/grpc"
@@ -42,21 +45,23 @@ const (
 // Compile time assert that this server implements the required grpc interface.
 var _ pb.FederationServer = (*Server)(nil)
 
-type iterateExposuresFunc func(context.Context, database.IterateExposuresCriteria, func(*database.Exposure) error) (string, error)
+type iterateExposuresFunc func(context.Context, database.IterateExposuresCriteria, func(*model.Exposure) error) (string, error)
 
 // NewServer builds a new FederationServer.
 func NewServer(env *serverenv.ServerEnv, config *Config) pb.FederationServer {
 	return &Server{
-		env:    env,
-		db:     env.Database(),
-		config: config,
+		env:       env,
+		db:        env.Database(),
+		publishdb: database.New(env.Database()),
+		config:    config,
 	}
 }
 
 type Server struct {
-	env    *serverenv.ServerEnv
-	db     *database.DB
-	config *Config
+	env       *serverenv.ServerEnv
+	db        *coredb.DB
+	publishdb *database.PublishDB
+	config    *Config
 }
 
 type authKey struct{}
@@ -66,7 +71,7 @@ func (s Server) Fetch(ctx context.Context, req *pb.FederationFetchRequest) (*pb.
 	ctx, cancel := context.WithTimeout(ctx, s.config.Timeout)
 	defer cancel()
 	logger := logging.FromContext(ctx)
-	response, err := s.fetch(ctx, req, s.db.IterateExposures, database.TruncateWindow(time.Now(), s.config.TruncateWindow)) // Don't fetch the current window, which isn't complete yet. TODO(squee1945): should I double this for safety?
+	response, err := s.fetch(ctx, req, s.publishdb.IterateExposures, model.TruncateWindow(time.Now(), s.config.TruncateWindow)) // Don't fetch the current window, which isn't complete yet. TODO(squee1945): should I double this for safety?
 	if err != nil {
 		s.env.MetricsExporter(ctx).WriteInt("federation-fetch-failed", true, 1)
 		logger.Errorf("Fetch error: %v", err)
@@ -91,7 +96,7 @@ func (s Server) fetch(ctx context.Context, req *pb.FederationFetchRequest, itFun
 	logger.Infof("Processing client request %#v", req)
 
 	// If there is a FederationAuthorization on the context, set the query to operate within its limits.
-	if auth, ok := ctx.Value(authKey{}).(*database.FederationOutAuthorization); ok {
+	if auth, ok := ctx.Value(authKey{}).(*coredb.FederationOutAuthorization); ok {
 		// For included regions, we INTERSECT the requested included regions with the configured included regions.
 		req.RegionIdentifiers = intersect(req.RegionIdentifiers, auth.IncludeRegions)
 		// For excluded regions, we UNION the the requested excluded regions with the configured excluded regions.
@@ -125,7 +130,7 @@ func (s Server) fetch(ctx context.Context, req *pb.FederationFetchRequest, itFun
 	ctiMap := map[string]*pb.ContactTracingInfo{}     // local index into the response being assembled; keys on unique set of (ctrMap key, transmissionRisk, verificationAuthorityName)
 	response := &pb.FederationFetchResponse{}
 	count := 0
-	cursor, err := itFunc(ctx, criteria, func(inf *database.Exposure) error {
+	cursor, err := itFunc(ctx, criteria, func(inf *model.Exposure) error {
 		// If the diagnosis key is empty, it's malformed, so skip it.
 		if len(inf.ExposureKey) == 0 {
 			logger.Debugf("Exposure %s missing ExposureKey, skipping.", inf.ExposureKey)
@@ -243,7 +248,7 @@ func (s Server) AuthInterceptor(ctx context.Context, req interface{}, info *grpc
 
 	auth, err := s.db.GetFederationOutAuthorization(ctx, token.Issuer, token.Subject)
 	if err != nil {
-		if errors.Is(err, database.ErrNotFound) {
+		if errors.Is(err, coredb.ErrNotFound) {
 			metrics.WriteInt("federation-fetch-unauthorized", true, 1)
 			logger.Infof("Authorization not found (issuer %q, subject %s)", token.Issuer, token.Subject)
 			return nil, status.Errorf(codes.Unauthenticated, "Invalid issuer/subject")
diff --git a/internal/pb/federation.pb.go b/internal/pb/federation.pb.go
index 11eec2b..3923714 100644
--- a/internal/pb/federation.pb.go
+++ b/internal/pb/federation.pb.go
@@ -255,6 +255,7 @@ type ContactTracingInfo struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
+	// Transmission risk is an integer with valid values from 1-8.
 	TransmissionRisk int32          `protobuf:"varint,1,opt,name=transmissionRisk,proto3" json:"transmissionRisk,omitempty"` // required
 	ExposureKeys     []*ExposureKey `protobuf:"bytes,2,rep,name=exposureKeys,proto3" json:"exposureKeys,omitempty"`
 }
diff --git a/internal/database/exposure.go b/internal/publish/database/exposure.go
similarity index 90%
rename from internal/database/exposure.go
rename to internal/publish/database/exposure.go
index 1237985..8c07898 100644
--- a/internal/database/exposure.go
+++ b/internal/publish/database/exposure.go
@@ -23,7 +23,9 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
+	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 
 	pgx "github.com/jackc/pgx/v4"
 )
@@ -33,6 +35,16 @@ const (
 	InsertExposuresBatchSize = 500
 )
 
+type PublishDB struct {
+	db *database.DB
+}
+
+func New(db *database.DB) *PublishDB {
+	return &PublishDB{
+		db: db,
+	}
+}
+
 // IterateExposuresCriteria is criteria to iterate exposures.
 type IterateExposuresCriteria struct {
 	IncludeRegions []string
@@ -54,8 +66,8 @@ type IterateExposuresCriteria struct {
 // criteria.LastCursor in a subsequent call to IterateExposures, will continue
 // the iteration at the failed row. If IterateExposures returns a nil error,
 // the first return value will be the empty string.
-func (db *DB) IterateExposures(ctx context.Context, criteria IterateExposuresCriteria, f func(*Exposure) error) (cur string, err error) {
-	conn, err := db.Pool.Acquire(ctx)
+func (db *PublishDB) IterateExposures(ctx context.Context, criteria IterateExposuresCriteria, f func(*model.Exposure) error) (cur string, err error) {
+	conn, err := db.db.Pool.Acquire(ctx)
 	if err != nil {
 		return "", fmt.Errorf("acquiring connection: %v", err)
 	}
@@ -94,7 +106,7 @@ func (db *DB) IterateExposures(ctx context.Context, criteria IterateExposuresCri
 			return cursor(), err
 		}
 		var (
-			m          Exposure
+			m          model.Exposure
 			encodedKey string
 			syncID     *int64
 		)
@@ -178,8 +190,8 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 }
 
 // InsertExposures inserts a set of exposures.
-func (db *DB) InsertExposures(ctx context.Context, exposures []*Exposure) error {
-	return db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+func (db *PublishDB) InsertExposures(ctx context.Context, exposures []*model.Exposure) error {
+	return db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		const stmtName = "insert exposures"
 		_, err := tx.Prepare(ctx, stmtName, `
 			INSERT INTO
@@ -210,10 +222,10 @@ func (db *DB) InsertExposures(ctx context.Context, exposures []*Exposure) error
 }
 
 // DeleteExposures deletes exposures created before "before" date. Returns the number of records deleted.
-func (db *DB) DeleteExposures(ctx context.Context, before time.Time) (int64, error) {
+func (db *PublishDB) DeleteExposures(ctx context.Context, before time.Time) (int64, error) {
 	var count int64
 	// ReadCommitted is sufficient here because we are dealing with historical, immutable rows.
-	err := db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+	err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		result, err := tx.Exec(ctx, `
 			DELETE FROM
 				Exposure
diff --git a/internal/database/exposure_model.go b/internal/publish/model/exposure_model.go
similarity index 99%
rename from internal/database/exposure_model.go
rename to internal/publish/model/exposure_model.go
index e5dae2b..611aab0 100644
--- a/internal/database/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package database
+package model
 
 import (
 	"crypto/sha256"
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 5b0ed49..8338fd0 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -22,9 +22,11 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/verification"
 )
@@ -40,9 +42,9 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		return nil, fmt.Errorf("missing AuthorizedApp provider in server environment")
 	}
 
-	transformer, err := database.NewTransformer(config.MaxKeysOnPublish, config.MaxIntervalAge, config.TruncateWindow)
+	transformer, err := model.NewTransformer(config.MaxKeysOnPublish, config.MaxIntervalAge, config.TruncateWindow)
 	if err != nil {
-		return nil, fmt.Errorf("database.NewTransformer: %w", err)
+		return nil, fmt.Errorf("model.NewTransformer: %w", err)
 	}
 	logger.Infof("max keys per upload: %v", config.MaxKeysOnPublish)
 	logger.Infof("max interval start age: %v", config.MaxIntervalAge)
@@ -52,7 +54,7 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		serverenv:             env,
 		transformer:           transformer,
 		config:                config,
-		database:              env.Database(),
+		database:              database.New(env.Database()),
 		authorizedAppProvider: env.AuthorizedAppProvider(),
 	}, nil
 }
@@ -60,8 +62,8 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 type publishHandler struct {
 	config                *Config
 	serverenv             *serverenv.ServerEnv
-	transformer           *database.Transformer
-	database              *database.DB
+	transformer           *model.Transformer
+	database              *database.PublishDB
 	authorizedAppProvider authorizedapp.Provider
 }
 
@@ -77,7 +79,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	ctx := r.Context()
 	logger := logging.FromContext(ctx)
 
-	var data database.Publish
+	var data model.Publish
 	code, err := jsonutil.Unmarshal(w, r, &data)
 	if err != nil {
 		// Log the unparsable JSON, but return success to the client.
diff --git a/internal/util/generate.go b/internal/util/generate.go
index 5871f92..32d9cd1 100644
--- a/internal/util/generate.go
+++ b/internal/util/generate.go
@@ -22,7 +22,7 @@ import (
 	"math/big"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/testing/enclient"
 )
 
@@ -58,7 +58,7 @@ func RandomIntWithMin(min, max int) (int, error) {
 }
 
 func RandomTransmissionRisk() (int, error) {
-	n, err := RandomInt(database.MaxTransmissionRisk)
+	n, err := RandomInt(model.MaxTransmissionRisk)
 	return n + 1, err
 }
 
@@ -70,14 +70,14 @@ func RandomArrValue(arr []string) (string, error) {
 	return arr[n], nil
 }
 
-func GenerateExposureKeys(numKeys, tr int) []database.ExposureKey {
+func GenerateExposureKeys(numKeys, tr int) []model.ExposureKey {
 	// When publishing multiple keys - they'll be on different days.
 	intervalCount, err := RandomIntervalCount()
 	if err != nil {
 		log.Fatalf("problem with random interval: %v", err)
 	}
 	intervalNumber := int32(time.Now().Unix()/600) - intervalCount
-	exposureKeys := make([]database.ExposureKey, numKeys)
+	exposureKeys := make([]model.ExposureKey, numKeys)
 	for i := 0; i < numKeys; i++ {
 		transmissionRisk := tr
 		if transmissionRisk < 0 {
@@ -102,12 +102,12 @@ func GenerateExposureKeys(numKeys, tr int) []database.ExposureKey {
 }
 
 // Creates a random exposure key.
-func RandomExposureKey(intervalNumber enclient.Interval, intervalCount int32, transmissionRisk int) (database.ExposureKey, error) {
+func RandomExposureKey(intervalNumber enclient.Interval, intervalCount int32, transmissionRisk int) (model.ExposureKey, error) {
 	key, err := GenerateKey()
 	if err != nil {
-		return database.ExposureKey{}, err
+		return model.ExposureKey{}, err
 	}
-	return database.ExposureKey{
+	return model.ExposureKey{
 		Key:              key,
 		IntervalNumber:   int32(intervalNumber),
 		IntervalCount:    intervalCount,
diff --git a/internal/verification/verify.go b/internal/verification/verify.go
index d429b4a..9750aab 100644
--- a/internal/verification/verify.go
+++ b/internal/verification/verify.go
@@ -21,7 +21,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	authorizedapp "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
-	"github.com/google/exposure-notifications-server/internal/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 
 	"github.com/google/exposure-notifications-server/internal/ios"
 )
@@ -33,7 +33,7 @@ var (
 
 // VerifyRegions checks the request regions against the regions allowed by
 // the configuration for the application.
-func VerifyRegions(cfg *authorizedapp.AuthorizedApp, data *database.Publish) error {
+func VerifyRegions(cfg *authorizedapp.AuthorizedApp, data *publishmodel.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("app configuration is empty")
 	}
@@ -50,7 +50,7 @@ func VerifyRegions(cfg *authorizedapp.AuthorizedApp, data *database.Publish) err
 
 // VerifySafetyNet verifies the Android SafetyNet device attestation against the
 // allowed configuration for the application.
-func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *authorizedapp.AuthorizedApp, publish *database.Publish) error {
+func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *authorizedapp.AuthorizedApp, publish *publishmodel.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("cannot enforce SafetyNet, missing config")
 	}
@@ -64,7 +64,7 @@ func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *authorized
 }
 
 // VerifyDeviceCheck verifies an iOS DeviceCheck token against the Apple API.
-func VerifyDeviceCheck(ctx context.Context, cfg *authorizedapp.AuthorizedApp, data *database.Publish) error {
+func VerifyDeviceCheck(ctx context.Context, cfg *authorizedapp.AuthorizedApp, data *publishmodel.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("cannot enforce DeviceCheck, missing config")
 	}
diff --git a/tools/export-generate/main.go b/tools/export-generate/main.go
index ab69341..fb076ea 100644
--- a/tools/export-generate/main.go
+++ b/tools/export-generate/main.go
@@ -29,7 +29,8 @@ import (
 	"math"
 	"time"
 
-	coredb "github.com/google/exposure-notifications-server/internal/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 
@@ -44,7 +45,7 @@ var (
 	region         = flag.String("region", "US", "The region for the test export.")
 	startTimestamp = flag.String("start-timestamp", "2020-05-01T15:00:00Z", "The test export start timestamp (RFC3339).")
 	endTimestamp   = flag.String("end-timestamp", "2020-05-02T15:00:00Z", "The test export end timestamp (RFC3339).")
-	numKeys        = flag.Int("num-keys", 450, "Number of total random temporary exposure keys to generate in the export")
+	numKeys        = flag.Int("num-keys", 450, "Number of total random temporary exposure keys to generate. Ignored if tek-file set.")
 	tekFile        = flag.String("tek-file", "", "JSON file of TEKs in the same format as calling publish endpoint")
 	batchSize      = flag.Int("batches-size", 100, "Max number of keys in each file in the batch")
 )
@@ -91,20 +92,20 @@ func main() {
 		log.Fatalf("problem with random transmission risk: %v", err)
 	}
 	var actualNumKeys int
-	var exposureKeys []coredb.Exposure
+	var exposureKeys []publishmodel.Exposure
 	if *tekFile != "" {
 		log.Printf("Using TEKs provided in: %s", *tekFile)
 		file, err := ioutil.ReadFile(*tekFile)
 		if err != nil {
 			log.Fatalf("unable to read file: %v", err)
 		}
-		data := coredb.ExposureKeys{}
+		data := publishmodel.ExposureKeys{}
 		err = json.Unmarshal([]byte(file), &data)
 		if err != nil {
 			log.Fatalf("unable to parse json: %v", err)
 		}
 		for _, k := range data.Keys {
-			ek, err := coredb.TransformExposureKey(k, "", []string{}, time.Now(), int32(0), math.MaxInt32)
+			ek, err := publishmodel.TransformExposureKey(k, "", []string{}, time.Now(), int32(0), math.MaxInt32)
 			if err != nil {
 				log.Fatalf("invalid exposure key: %v", err)
 			}
@@ -112,10 +113,11 @@ func main() {
 		}
 		actualNumKeys = len(exposureKeys)
 	} else {
+		log.Printf("Genrating %d random TEKs", *numKeys)
 		keys := util.GenerateExposureKeys(*numKeys, tr)
 		actualNumKeys = *numKeys
 
-		exposureKeys = make([]coredb.Exposure, actualNumKeys)
+		exposureKeys = make([]publishmodel.Exposure, actualNumKeys)
 		for i, k := range keys {
 			decoded, err := base64.StdEncoding.DecodeString(k.Key)
 			if err != nil {
@@ -141,13 +143,13 @@ func main() {
 	numBatches := int(math.Ceil(float64(actualNumKeys) / float64(*batchSize)))
 	log.Printf("number of batches: %d", numBatches)
 	b := 0
-	currentBatch := []*coredb.Exposure{}
+	currentBatch := []*publishmodel.Exposure{}
 	for i := 0; i < actualNumKeys; i++ {
 		currentBatch = append(currentBatch, &exposureKeys[i])
 		if len(currentBatch) == *batchSize {
 			b++
 			writeFile(eb, currentBatch, b, numBatches, actualNumKeys, privateKey)
-			currentBatch = []*coredb.Exposure{}
+			currentBatch = []*publishmodel.Exposure{}
 		}
 	}
 	if len(currentBatch) > 0 {
@@ -156,7 +158,7 @@ func main() {
 	}
 }
 
-func writeFile(eb *model.ExportBatch, currentBatch []*coredb.Exposure, b, numBatches, numRecords int, privateKey *ecdsa.PrivateKey) {
+func writeFile(eb *model.ExportBatch, currentBatch []*publishmodel.Exposure, b, numBatches, numRecords int, privateKey *ecdsa.PrivateKey) {
 	signatureInfo := &model.SignatureInfo{
 		SigningKeyID:      *keyID,
 		SigningKeyVersion: *keyVersion,
diff --git a/tools/exposure-client/main.go b/tools/exposure-client/main.go
index 32a7b49..73e5142 100644
--- a/tools/exposure-client/main.go
+++ b/tools/exposure-client/main.go
@@ -24,7 +24,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/util"
 	"github.com/google/exposure-notifications-server/testing/enclient"
 )
@@ -84,7 +84,7 @@ func main() {
 		log.Printf("could not get random padding: %v", err)
 	}
 
-	data := database.Publish{
+	data := model.Publish{
 		Keys:           exposureKeys,
 		Regions:        region,
 		AppPackageName: *appPackage,


Test patch:
diff --git a/internal/android/safetynet_test.go b/internal/android/safetynet_test.go
index 6441744..bc8e3ff 100644
--- a/internal/android/safetynet_test.go
+++ b/internal/android/safetynet_test.go
@@ -21,7 +21,8 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	"github.com/google/exposure-notifications-server/internal/base64util"
-	"github.com/google/exposure-notifications-server/internal/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -39,8 +40,8 @@ var (
 	// **This comment applies to all test data in this file.**
 	payload = `eyJhbGciOiJSUzI1NiIsIng1YyI6WyJNSUlGa3pDQ0JIdWdBd0lCQWdJUkFOY1NramRzNW42K0NBQUFBQUFwYTBjd0RRWUpLb1pJaHZjTkFRRUxCUUF3UWpFTE1Ba0dBMVVFQmhNQ1ZWTXhIakFjQmdOVkJBb1RGVWR2YjJkc1pTQlVjblZ6ZENCVFpYSjJhV05sY3pFVE1CRUdBMVVFQXhNS1IxUlRJRU5CSURGUE1UQWVGdzB5TURBeE1UTXhNVFF4TkRsYUZ3MHlNVEF4TVRFeE1UUXhORGxhTUd3eEN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUlFd3BEWVd4cFptOXlibWxoTVJZd0ZBWURWUVFIRXcxTmIzVnVkR0ZwYmlCV2FXVjNNUk13RVFZRFZRUUtFd3BIYjI5bmJHVWdURXhETVJzd0dRWURWUVFERXhKaGRIUmxjM1F1WVc1a2NtOXBaQzVqYjIwd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUNXRXJCUVRHWkdOMWlaYk45ZWhSZ2lmV0J4cWkyUGRneHcwM1A3VHlKWmZNeGpwNUw3ajFHTmVQSzVIemRyVW9JZDF5Q0l5Qk15eHFnYXpxZ3RwWDVXcHNYVzRWZk1oSmJOMVkwOXF6cXA2SkQrMlBaZG9UVTFrRlJBTVdmTC9VdVp0azdwbVJYZ0dtNWpLRHJaOU54ZTA0dk1ZUXI4OE5xd1cva2ZaMWdUT05JVVQwV3NMVC80NTIyQlJXeGZ3eGMzUUUxK1RLV2tMQ3J2ZWs2V2xJcXlhQzUyVzdNRFI4TXBGZWJ5bVNLVHZ3Zk1Sd3lLUUxUMDNVTDR2dDQ4eUVjOHNwN3dUQUhNL1dEZzhRb3RhcmY4T0JIa25vWjkyWGl2aWFWNnRRcWhST0hDZmdtbkNYaXhmVzB3RVhDdnFpTFRiUXRVYkxzUy84SVJ0ZFhrcFFCOUFnTUJBQUdqZ2dKWU1JSUNWREFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd0V3REFZRFZSMFRBUUgvQkFJd0FEQWRCZ05WSFE0RUZnUVU2REhCd3NBdmI1M2cvQzA3cHJUdnZ3TlFRTFl3SHdZRFZSMGpCQmd3Rm9BVW1OSDRiaERyejV2c1lKOFlrQnVnNjMwSi9Tc3daQVlJS3dZQkJRVUhBUUVFV0RCV01DY0dDQ3NHQVFVRkJ6QUJoaHRvZEhSd09pOHZiMk56Y0M1d2Eya3VaMjl2Wnk5bmRITXhiekV3S3dZSUt3WUJCUVVITUFLR0gyaDBkSEE2THk5d2Eya3VaMjl2Wnk5bmMzSXlMMGRVVXpGUE1TNWpjblF3SFFZRFZSMFJCQll3RklJU1lYUjBaWE4wTG1GdVpISnZhV1F1WTI5dE1DRUdBMVVkSUFRYU1CZ3dDQVlHWjRFTUFRSUNNQXdHQ2lzR0FRUUIxbmtDQlFNd0x3WURWUjBmQkNnd0pqQWtvQ0tnSUlZZWFIUjBjRG92TDJOeWJDNXdhMmt1WjI5dlp5OUhWRk14VHpFdVkzSnNNSUlCQkFZS0t3WUJCQUhXZVFJRUFnU0I5UVNCOGdEd0FIY0E5bHlVTDlGM01DSVVWQmdJTUpSV2p1Tk5FeGt6djk4TUx5QUx6RTd4Wk9NQUFBRnZudXkwWndBQUJBTUFTREJHQWlFQTdlLzBZUnUzd0FGbVdIMjdNMnZiVmNaL21ycCs0cmZZYy81SVBKMjlGNmdDSVFDbktDQ0FhY1ZOZVlaOENDZllkR3BCMkdzSHh1TU9Ia2EvTzQxaldlRit6Z0IxQUVTVVpTNnc3czZ2eEVBSDJLaitLTURhNW9LKzJNc3h0VC9UTTVhMXRvR29BQUFCYjU3c3RKTUFBQVFEQUVZd1JBSWdFWGJpb1BiSnA5cUMwRGoyNThERkdTUk1BVStaQjFFaVZFYmJiLzRVdk5FQ0lCaEhrQnQxOHZSbjl6RHZ5cmZ4eXVkY0hUT1NsM2dUYVlBLzd5VC9CaUg0TUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFESUFjUUJsbWQ4TUVnTGRycnJNYkJUQ3ZwTVhzdDUrd3gyRGxmYWpKTkpVUDRqWUZqWVVROUIzWDRFMnpmNDluWDNBeXVaRnhBcU9SbmJqLzVqa1k3YThxTUowajE5ekZPQitxZXJ4ZWMwbmhtOGdZbExiUW02c0tZN1AwZXhmcjdIdUszTWtQMXBlYzE0d0ZFVWFHcUR3VWJHZ2wvb2l6MzhGWENFK0NXOEUxUUFFVWZ2YlFQVFliS3hZait0Q05sc3MwYlRTb0wyWjJkL2ozQnBMM01GdzB5eFNLL1VUcXlrTHIyQS9NZGhKUW14aStHK01LUlNzUXI2MkFuWmF1OXE2WUZvaSs5QUVIK0E0OFh0SXlzaEx5Q1RVM0h0K2FLb2hHbnhBNXVsMVhSbXFwOEh2Y0F0MzlQOTVGWkdGSmUwdXZseWpPd0F6WHVNdTdNK1BXUmMiLCJNSUlFU2pDQ0F6S2dBd0lCQWdJTkFlTzBtcUdOaXFtQkpXbFF1REFOQmdrcWhraUc5dzBCQVFzRkFEQk1NU0F3SGdZRFZRUUxFeGRIYkc5aVlXeFRhV2R1SUZKdmIzUWdRMEVnTFNCU01qRVRNQkVHQTFVRUNoTUtSMnh2WW1Gc1UybG5iakVUTUJFR0ExVUVBeE1LUjJ4dlltRnNVMmxuYmpBZUZ3MHhOekEyTVRVd01EQXdOREphRncweU1URXlNVFV3TURBd05ESmFNRUl4Q3pBSkJnTlZCQVlUQWxWVE1SNHdIQVlEVlFRS0V4VkhiMjluYkdVZ1ZISjFjM1FnVTJWeWRtbGpaWE14RXpBUkJnTlZCQU1UQ2tkVVV5QkRRU0F4VHpFd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUURRR005RjFJdk4wNXprUU85K3ROMXBJUnZKenp5T1RIVzVEekVaaEQyZVBDbnZVQTBRazI4RmdJQ2ZLcUM5RWtzQzRUMmZXQllrL2pDZkMzUjNWWk1kUy9kTjRaS0NFUFpSckF6RHNpS1VEelJybUJCSjV3dWRnem5kSU1ZY0xlL1JHR0ZsNXlPRElLZ2pFdi9TSkgvVUwrZEVhbHROMTFCbXNLK2VRbU1GKytBY3hHTmhyNTlxTS85aWw3MUkyZE44RkdmY2Rkd3VhZWo0YlhocDBMY1FCYmp4TWNJN0pQMGFNM1Q0SStEc2F4bUtGc2JqemFUTkM5dXpwRmxnT0lnN3JSMjV4b3luVXh2OHZObWtxN3pkUEdIWGt4V1k3b0c5aitKa1J5QkFCazdYckpmb3VjQlpFcUZKSlNQazdYQTBMS1cwWTN6NW96MkQwYzF0Skt3SEFnTUJBQUdqZ2dFek1JSUJMekFPQmdOVkhROEJBZjhFQkFNQ0FZWXdIUVlEVlIwbEJCWXdGQVlJS3dZQkJRVUhBd0VHQ0NzR0FRVUZCd01DTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0hRWURWUjBPQkJZRUZKalIrRzRRNjgrYjdHQ2ZHSkFib090OUNmMHJNQjhHQTFVZEl3UVlNQmFBRkp2aUIxZG5IQjdBYWdiZVdiU2FMZC9jR1lZdU1EVUdDQ3NHQVFVRkJ3RUJCQ2t3SnpBbEJnZ3JCZ0VGQlFjd0FZWVphSFIwY0RvdkwyOWpjM0F1Y0d0cExtZHZiMmN2WjNOeU1qQXlCZ05WSFI4RUt6QXBNQ2VnSmFBamhpRm9kSFJ3T2k4dlkzSnNMbkJyYVM1bmIyOW5MMmR6Y2pJdlozTnlNaTVqY213d1B3WURWUjBnQkRnd05qQTBCZ1puZ1F3QkFnSXdLakFvQmdnckJnRUZCUWNDQVJZY2FIUjBjSE02THk5d2Eya3VaMjl2Wnk5eVpYQnZjMmwwYjNKNUx6QU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFHb0ErTm5uNzh5NnBSamQ5WGxRV05hN0hUZ2laL3IzUk5Ha21VbVlIUFFxNlNjdGk5UEVhanZ3UlQyaVdUSFFyMDJmZXNxT3FCWTJFVFV3Z1pRK2xsdG9ORnZoc085dHZCQ09JYXpwc3dXQzlhSjl4anU0dFdEUUg4TlZVNllaWi9YdGVEU0dVOVl6SnFQalk4cTNNRHhyem1xZXBCQ2Y1bzhtdy93SjRhMkc2eHpVcjZGYjZUOE1jRE8yMlBMUkw2dTNNNFR6czNBMk0xajZieWtKWWk4d1dJUmRBdktMV1p1L2F4QlZielltcW13a201ekxTRFc1bklBSmJFTENRQ1p3TUg1NnQyRHZxb2Z4czZCQmNDRklaVVNweHU2eDZ0ZDBWN1N2SkNDb3NpclNtSWF0ai85ZFNTVkRRaWJldDhxLzdVSzR2NFpVTjgwYXRuWnoxeWc9PSJdfQ.eyJub25jZSI6IlZreEphblUwYW5CcFkydDNObEZFZVM5d2IzRk9iRlprTHpCaFNYTktiRTVqYkVGSmFESnFRbm94VFQwPSIsInRpbWVzdGFtcE1zIjoxNTg5NTg4NDA4MTg1LCJhcGtQYWNrYWdlTmFtZSI6ImNvbS5nb29nbGUuYW5kcm9pZC5hcHBzLmV4cG9zdXJlbm90aWZpY2F0aW9uIiwiYXBrRGlnZXN0U2hhMjU2IjoiYWt2Y3oxdWgyak85NzF5blZ1bXdsSEM4L2xHZG5vYjVGVlNIWVlabCtWcz0iLCJjdHNQcm9maWxlTWF0Y2giOnRydWUsImFwa0NlcnRpZmljYXRlRGlnZXN0U2hhMjU2IjpbImpxbVlFcWk5cVV2cFVlMTFxTWYzdjJvNlZFUU0rNU5EZWUyYnoweGR6V2M9Il0sImJhc2ljSW50ZWdyaXR5Ijp0cnVlLCJldmFsdWF0aW9uVHlwZSI6IkJBU0lDIn0.cU97Z9Fax39ehQvKpa8C0I0hXIh1zclZErn5uDGdtWsVTddpf1qxSVnAwxAGhBmqbOF_YL-75dGojxT8dsGpe7LBXQr9qyRm79FjfIAAqDQrblvOxIatGbR1qkmLJITF7DAc4sAcK8CP2RxIrAkUIAUAdgwsx4JbXeNMfPxR-XODMo93MaMRLV5byA1MDaRw3RA-HTx81LEAuQJfUcoRf-O7WTnBBYDUDlUAY5_VNp7wGRKg9eNNzbbysPqpS-o-sW67dvsalav2ENwwQT9RJwWR4YCI4bDh_qH3Mz8FfChwefS1AW4nOPsOJwkEhLHIO521ZQiwZVct1b6glPc08g`
 
-	publish = &database.Publish{
-		Keys: []database.ExposureKey{
+	publish = &publishmodel.Publish{
+		Keys: []publishmodel.ExposureKey{
 			{Key: "QLIvVheW9p6JiTx4pslesg==", IntervalNumber: 2649312, IntervalCount: 144, TransmissionRisk: 1},
 			{Key: "UW7pkDKfLbfLs8LveFyY3w==", IntervalNumber: 2649168, IntervalCount: 144, TransmissionRisk: 1},
 		},
diff --git a/internal/authorizedapp/database/authorized_app_test.go b/internal/authorizedapp/database/authorized_app_test.go
index ac90c85..4781166 100644
--- a/internal/authorizedapp/database/authorized_app_test.go
+++ b/internal/authorizedapp/database/authorized_app_test.go
@@ -59,6 +59,7 @@ func TestMain(m *testing.M) {
 	}
 	os.Exit(m.Run())
 }
+
 func TestGetAuthorizedApp(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 52872b6..c1f1d92 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -25,6 +25,8 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/model"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 	pgx "github.com/jackc/pgx/v4"
 )
@@ -477,21 +479,21 @@ func TestKeysInBatch(t *testing.T) {
 	}
 
 	// Create key aligned with the StartTimestamp
-	sek := &database.Exposure{
+	sek := &publishmodel.Exposure{
 		ExposureKey: []byte("aaa"),
 		Regions:     []string{ec.Region},
 		CreatedAt:   startTimestamp,
 	}
 
 	// Create key aligned with the EndTimestamp
-	eek := &database.Exposure{
+	eek := &publishmodel.Exposure{
 		ExposureKey: []byte("bbb"),
 		Regions:     []string{ec.Region},
 		CreatedAt:   endTimestamp,
 	}
 
 	// Add the keys to the database.
-	if err := testDB.InsertExposures(ctx, []*database.Exposure{sek, eek}); err != nil {
+	if err := publishdb.New(testDB).InsertExposures(ctx, []*publishmodel.Exposure{sek, eek}); err != nil {
 		t.Fatal(err)
 	}
 
@@ -512,14 +514,14 @@ func TestKeysInBatch(t *testing.T) {
 
 	// Lookup the keys; they must be only the key created_at the startTimestamp
 	// (because start is inclusive, end is exclusive).
-	criteria := database.IterateExposuresCriteria{
+	criteria := publishdb.IterateExposuresCriteria{
 		IncludeRegions: []string{leased.Region},
 		SinceTimestamp: leased.StartTimestamp,
 		UntilTimestamp: leased.EndTimestamp,
 	}
 
-	var got []*database.Exposure
-	_, err = testDB.IterateExposures(ctx, criteria, func(exp *database.Exposure) error {
+	var got []*publishmodel.Exposure
+	_, err = publishdb.New(testDB).IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
 		got = append(got, exp)
 		return nil
 	})
diff --git a/internal/export/worker_test.go b/internal/export/worker_test.go
index 4407d3b..4755504 100644
--- a/internal/export/worker_test.go
+++ b/internal/export/worker_test.go
@@ -19,24 +19,24 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 )
 
 func TestRandomInt(t *testing.T) {
 	expected := make(map[int]bool)
-	for i := database.MinTransmissionRisk; i <= database.MaxTransmissionRisk; i++ {
+	for i := model.MinTransmissionRisk; i <= model.MaxTransmissionRisk; i++ {
 		expected[i] = true
 	}
 
 	// Run through 1,000 iterations. To ensure the entire range can be hit.
 	for i := 0; i < 1000; i++ {
-		v, err := randomInt(database.MinTransmissionRisk, database.MaxTransmissionRisk)
+		v, err := randomInt(model.MinTransmissionRisk, model.MaxTransmissionRisk)
 		if err != nil {
 			t.Fatalf("error getting random data")
 		}
-		if v < database.MinTransmissionRisk || v > database.MaxTransmissionRisk {
+		if v < model.MinTransmissionRisk || v > model.MaxTransmissionRisk {
 			t.Fatalf("random data outside expected bounds. %v <= %v <= %v",
-				database.MinTransmissionRisk, v, database.MaxTransmissionRisk)
+				model.MinTransmissionRisk, v, model.MaxTransmissionRisk)
 		}
 		delete(expected, v)
 	}
@@ -46,7 +46,7 @@ func TestRandomInt(t *testing.T) {
 }
 
 func TestDoNotPadZeroLength(t *testing.T) {
-	exposures := make([]*database.Exposure, 0)
+	exposures := make([]*model.Exposure, 0)
 	exposures, err := ensureMinNumExposures(exposures, "US", 1000, 100)
 	if err != nil {
 		t.Fatalf("unepected error: %v", err)
@@ -56,14 +56,14 @@ func TestDoNotPadZeroLength(t *testing.T) {
 	}
 }
 
-func addExposure(t *testing.T, exposures []*database.Exposure, interval, count int32, risk int) []*database.Exposure {
-	key := make([]byte, database.KeyLength)
+func addExposure(t *testing.T, exposures []*model.Exposure, interval, count int32, risk int) []*model.Exposure {
+	key := make([]byte, model.KeyLength)
 	_, err := rand.Read(key)
 	if err != nil {
 		t.Fatalf("error getting random data: %v", err)
 	}
 	return append(exposures,
-		&database.Exposure{
+		&model.Exposure{
 			ExposureKey:      key,
 			TransmissionRisk: risk,
 			IntervalNumber:   interval,
@@ -73,11 +73,11 @@ func addExposure(t *testing.T, exposures []*database.Exposure, interval, count i
 
 func TestEnsureMinExposures(t *testing.T) {
 	expectedTR := make(map[int]bool)
-	for i := database.MinTransmissionRisk; i <= database.MaxTransmissionRisk; i++ {
+	for i := model.MinTransmissionRisk; i <= model.MaxTransmissionRisk; i++ {
 		expectedTR[i] = true
 	}
 	// Insert a few exposures - that will be used to base the interval information off of.
-	exposures := make([]*database.Exposure, 0)
+	exposures := make([]*model.Exposure, 0)
 	exposures = addExposure(t, exposures, 123456, 144, 0)
 	exposures = addExposure(t, exposures, 789101, 88, 0)
 	// all of these combinations are expected
diff --git a/internal/federationin/federationin_test.go b/internal/federationin/federationin_test.go
index 9fd0ffa..8daef98 100644
--- a/internal/federationin/federationin_test.go
+++ b/internal/federationin/federationin_test.go
@@ -20,6 +20,8 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/pb"
 
@@ -38,7 +40,7 @@ var (
 )
 
 // makeRemoteExposure returns a mock model.Exposure with LocalProvenance=false.
-func makeRemoteExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *database.Exposure {
+func makeRemoteExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *model.Exposure {
 	inf := makeExposure(diagKey, diagStatus, regions...)
 	inf.LocalProvenance = false
 	inf.FederationSyncID = syncID
@@ -62,12 +64,12 @@ func (r *remoteFetchServer) fetch(ctx context.Context, req *pb.FederationFetchRe
 	return response, nil
 }
 
-// exposureDB mocks the database, recording exposure insertions.
-type exposureDB struct {
-	exposures []*database.Exposure
+// publishDB mocks the database, recording exposure insertions.
+type publishDB struct {
+	exposures []*model.Exposure
 }
 
-func (idb *exposureDB) insertExposures(ctx context.Context, exposures []*database.Exposure) error {
+func (idb *publishDB) insertExposures(ctx context.Context, exposures []*model.Exposure) error {
 	idb.exposures = append(idb.exposures, exposures...)
 	return nil
 }
@@ -99,7 +101,7 @@ func TestFederationPull(t *testing.T) {
 		name             string
 		batchSize        int
 		fetchResponses   []*pb.FederationFetchResponse
-		wantExposures    []*database.Exposure
+		wantExposures    []*model.Exposure
 		wantTokens       []string
 		wantMaxTimestamp time.Time
 	}{
@@ -135,7 +137,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 1, "US"),
 				makeRemoteExposure(bbb, 1, "US"),
 				makeRemoteExposure(ccc, 2, "CA", "US"),
@@ -171,7 +173,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 1, "US"),
 				makeRemoteExposure(bbb, 1, "US"),
 			},
@@ -212,7 +214,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 8, "US"),
 				makeRemoteExposure(bbb, 8, "US"),
 				makeRemoteExposure(ccc, 7, "US"),
@@ -249,7 +251,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 3, "US"),
 				makeRemoteExposure(bbb, 3, "US"),
 				makeRemoteExposure(ccc, 2, "CA", "US"),
@@ -265,7 +267,7 @@ func TestFederationPull(t *testing.T) {
 			ctx := context.Background()
 			query := &database.FederationInQuery{}
 			remote := remoteFetchServer{responses: tc.fetchResponses}
-			idb := exposureDB{}
+			idb := publishDB{}
 			sdb := syncDB{}
 			batchStart := time.Now()
 			if tc.batchSize > 0 {
@@ -284,7 +286,7 @@ func TestFederationPull(t *testing.T) {
 				t.Fatalf("pull returned err=%v, want err=nil", err)
 			}
 
-			if diff := cmp.Diff(tc.wantExposures, idb.exposures, cmpopts.IgnoreFields(database.Exposure{}, "CreatedAt")); diff != "" {
+			if diff := cmp.Diff(tc.wantExposures, idb.exposures, cmpopts.IgnoreFields(model.Exposure{}, "CreatedAt")); diff != "" {
 				t.Errorf("exposures mismatch (-want +got):\n%s", diff)
 			}
 			if diff := cmp.Diff(tc.wantTokens, remote.gotTokens); diff != "" {
@@ -309,8 +311,8 @@ func TestFederationPull(t *testing.T) {
 	}
 }
 
-func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *database.Exposure {
-	return &database.Exposure{
+func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *model.Exposure {
+	return &model.Exposure{
 		Regions:          regions,
 		TransmissionRisk: diagStatus,
 		ExposureKey:      diagKey.ExposureKey,
diff --git a/internal/federationout/federationout_test.go b/internal/federationout/federationout_test.go
index 57cea09..97660df 100644
--- a/internal/federationout/federationout_test.go
+++ b/internal/federationout/federationout_test.go
@@ -20,7 +20,10 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/pb"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"google.golang.org/grpc/metadata"
@@ -44,9 +47,9 @@ var (
 	ddd = &pb.ExposureKey{ExposureKey: []byte("ddd"), IntervalNumber: 4}
 )
 
-// makeExposure returns a mock database.Exposure.
-func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *database.Exposure {
-	return &database.Exposure{
+// makeExposure returns a mock model.Exposure.
+func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *model.Exposure {
+	return &model.Exposure{
 		Regions:          regions,
 		TransmissionRisk: diagStatus,
 		ExposureKey:      diagKey.ExposureKey,
@@ -60,11 +63,11 @@ func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *d
 type timeout struct{}
 
 func iterFunc(elements []interface{}) iterateExposuresFunc {
-	return func(_ context.Context, _ database.IterateExposuresCriteria, f func(*database.Exposure) error) (string, error) {
+	return func(_ context.Context, _ database.IterateExposuresCriteria, f func(*model.Exposure) error) (string, error) {
 		var cursor string
 		for _, el := range elements {
 			switch v := el.(type) {
-			case *database.Exposure:
+			case *model.Exposure:
 				// Set the cursor to the most recent diagnosis key, suffixed with "_cursor".
 				cursor = string(v.ExposureKey) + "_cursor"
 				if err := f(v); err != nil {
diff --git a/internal/jsonutil/unmarshal_test.go b/internal/jsonutil/unmarshal_test.go
index 1a410ec..6b520dc 100644
--- a/internal/jsonutil/unmarshal_test.go
+++ b/internal/jsonutil/unmarshal_test.go
@@ -23,7 +23,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -33,7 +33,7 @@ func TestInvalidHeader(t *testing.T) {
 	r.Header.Set("content-type", "application/text")
 
 	w := httptest.NewRecorder()
-	data := &database.Publish{}
+	data := &model.Publish{}
 	code, err := Unmarshal(w, r, data)
 
 	expCode := http.StatusUnsupportedMediaType
@@ -130,7 +130,7 @@ func TestValidPublishMessage(t *testing.T) {
 
 	w := httptest.NewRecorder()
 
-	got := &database.Publish{}
+	got := &model.Publish{}
 	code, err := Unmarshal(w, r, got)
 	if err != nil {
 		t.Fatalf("unexpected err, %v", err)
@@ -139,8 +139,8 @@ func TestValidPublishMessage(t *testing.T) {
 		t.Errorf("unmarshal wanted %v response code, got %v", http.StatusOK, code)
 	}
 
-	want := &database.Publish{
-		Keys: []database.ExposureKey{
+	want := &model.Publish{
+		Keys: []model.ExposureKey{
 			{Key: "ABC", IntervalNumber: intervalNumber, IntervalCount: 144, TransmissionRisk: 2},
 			{Key: "DEF", IntervalNumber: intervalNumber, IntervalCount: 122, TransmissionRisk: 2},
 			{Key: "123", IntervalNumber: intervalNumber, IntervalCount: 1, TransmissionRisk: 2},
@@ -164,7 +164,7 @@ func unmarshalTestHelper(t *testing.T, payloads []string, errors []string, expCo
 		r.Header.Set("content-type", "application/json")
 
 		w := httptest.NewRecorder()
-		data := &database.Publish{}
+		data := &model.Publish{}
 		code, err := Unmarshal(w, r, data)
 		if code != expCode {
 			t.Errorf("unmarshal wanted %v response code, got %v", expCode, code)
diff --git a/internal/database/exposure_test.go b/internal/publish/database/exposure_test.go
similarity index 78%
rename from internal/database/exposure_test.go
rename to internal/publish/database/exposure_test.go
index d737288..329bb99 100644
--- a/internal/database/exposure_test.go
+++ b/internal/publish/database/exposure_test.go
@@ -17,22 +17,43 @@ package database
 import (
 	"context"
 	"errors"
+	"log"
+	"os"
 	"testing"
 	"time"
 
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 )
 
+var testDB *coredb.DB
+var testPublishDB *PublishDB
+
+func TestMain(m *testing.M) {
+	ctx := context.Background()
+
+	if os.Getenv("DB_USER") != "" {
+		var err error
+		testDB, err = coredb.CreateTestDB(ctx)
+		if err != nil {
+			log.Fatalf("creating test DB: %v", err)
+		}
+		testPublishDB = New(testDB)
+	}
+	os.Exit(m.Run())
+}
 func TestExposures(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
 	}
-	defer ResetTestDB(t, testDB)
+	defer coredb.ResetTestDB(t, testDB)
 	ctx := context.Background()
 
 	// Insert some Exposures.
 	batchTime := time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC).Truncate(time.Microsecond)
-	exposures := []*Exposure{
+	exposures := []*model.Exposure{
 		{
 			ExposureKey:     []byte("ABC"),
 			Regions:         []string{"US", "CA", "MX"},
@@ -66,7 +87,7 @@ func TestExposures(t *testing.T) {
 			LocalProvenance: false,
 		},
 	}
-	if err := testDB.InsertExposures(ctx, exposures); err != nil {
+	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 
@@ -112,7 +133,7 @@ func TestExposures(t *testing.T) {
 		if err != nil {
 			t.Fatalf("%+v: %v", test.criteria, err)
 		}
-		var want []*Exposure
+		var want []*model.Exposure
 		for _, i := range test.want {
 			want = append(want, exposures[i])
 		}
@@ -122,7 +143,7 @@ func TestExposures(t *testing.T) {
 	}
 
 	// Delete some exposures.
-	gotN, err := testDB.DeleteExposures(ctx, exposures[2].CreatedAt)
+	gotN, err := testPublishDB.DeleteExposures(ctx, exposures[2].CreatedAt)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -140,9 +161,9 @@ func TestExposures(t *testing.T) {
 
 }
 
-func listExposures(ctx context.Context, c IterateExposuresCriteria) (_ []*Exposure, err error) {
-	var exps []*Exposure
-	_, err = testDB.IterateExposures(ctx, c, func(e *Exposure) error {
+func listExposures(ctx context.Context, c IterateExposuresCriteria) (_ []*model.Exposure, err error) {
+	var exps []*model.Exposure
+	_, err = testPublishDB.IterateExposures(ctx, c, func(e *model.Exposure) error {
 		exps = append(exps, e)
 		return nil
 	})
@@ -156,10 +177,10 @@ func TestIterateExposuresCursor(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
 	}
-	defer ResetTestDB(t, testDB)
+	defer coredb.ResetTestDB(t, testDB)
 	ctx, cancel := context.WithCancel(context.Background())
 	// Insert some Exposures.
-	exposures := []*Exposure{
+	exposures := []*model.Exposure{
 		{
 			ExposureKey:    []byte("ABC"),
 			Regions:        []string{"US", "CA", "MX"},
@@ -176,12 +197,12 @@ func TestIterateExposuresCursor(t *testing.T) {
 			Regions:        []string{"MX", "CA"},
 		},
 	}
-	if err := testDB.InsertExposures(ctx, exposures); err != nil {
+	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 	// Iterate over them, canceling the context in the middle.
-	var seen []*Exposure
-	cursor, err := testDB.IterateExposures(ctx, IterateExposuresCriteria{}, func(e *Exposure) error {
+	var seen []*model.Exposure
+	cursor, err := testPublishDB.IterateExposures(ctx, IterateExposuresCriteria{}, func(e *model.Exposure) error {
 		seen = append(seen, e)
 		if len(seen) == 2 {
 			cancel()
@@ -200,8 +221,8 @@ func TestIterateExposuresCursor(t *testing.T) {
 	// Resume from the cursor.
 	ctx = context.Background()
 	seen = nil
-	cursor, err = testDB.IterateExposures(ctx, IterateExposuresCriteria{LastCursor: cursor},
-		func(e *Exposure) error { seen = append(seen, e); return nil })
+	cursor, err = testPublishDB.IterateExposures(ctx, IterateExposuresCriteria{LastCursor: cursor},
+		func(e *model.Exposure) error { seen = append(seen, e); return nil })
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/database/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
similarity index 99%
rename from internal/database/exposure_model_test.go
rename to internal/publish/model/exposure_model_test.go
index 63630e7..58e72ac 100644
--- a/internal/database/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package database
+package model
 
 import (
 	"crypto/rand"
diff --git a/internal/verification/verify_test.go b/internal/verification/verify_test.go
index 2ce3c5e..b87de9c 100644
--- a/internal/verification/verify_test.go
+++ b/internal/verification/verify_test.go
@@ -23,7 +23,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	authorizedapp "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 )
 
 const (
@@ -33,26 +33,26 @@ const (
 func TestVerifyRegions(t *testing.T) {
 	cases := []struct {
 		name string
-		data *database.Publish
+		data *model.Publish
 		cfg  *authorizedapp.AuthorizedApp
 		err  bool
 	}{
 		{
 			name: "nil_config",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg:  nil,
 			err:  true,
 		},
 		{
 			name: "nil_regions_allows_all",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 			},
 		},
 		{
 			name: "empty_regions_allows_all",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{},
@@ -60,7 +60,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_one",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -71,7 +71,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_all",
-			data: &database.Publish{Regions: []string{"US", "CA"}},
+			data: &model.Publish{Regions: []string{"US", "CA"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -82,7 +82,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_some",
-			data: &database.Publish{Regions: []string{"US", "MX"}},
+			data: &model.Publish{Regions: []string{"US", "MX"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -94,7 +94,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_none",
-			data: &database.Publish{Regions: []string{"MX"}},
+			data: &model.Publish{Regions: []string{"MX"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -123,27 +123,27 @@ func TestVerifySafetyNet(t *testing.T) {
 	}
 
 	cases := []struct {
-		Data              *database.Publish
+		Data              *model.Publish
 		Msg               string
 		Cfg               *authorizedapp.AuthorizedApp
 		AttestationResult error
 	}{
 		{
 			// With no configuration, return err.
-			&database.Publish{Regions: []string{"US"}},
+			&model.Publish{Regions: []string{"US"}},
 			"cannot enforce SafetyNet",
 			nil,
 			nil,
 		}, {
 			// Verify when Validate Attestation Passes, return nil.
-			&database.Publish{Regions: []string{"US"}},
+			&model.Publish{Regions: []string{"US"}},
 			"",
 			allRegions,
 			nil,
 		}, {
 			// Verify when ValidateAttestation raises err, with safety check
 			// enabled, return err.
-			&database.Publish{Regions: []string{"US"}},
+			&model.Publish{Regions: []string{"US"}},
 			"android.ValidateAttestation: mocked",
 			allRegions,
 			fmt.Errorf("mocked"),
diff --git a/testing/enclient/enclient.go b/testing/enclient/enclient.go
index 7cc5361..b6046e4 100644
--- a/testing/enclient/enclient.go
+++ b/testing/enclient/enclient.go
@@ -9,7 +9,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 )
 
 const (
@@ -70,8 +70,8 @@ func NewInterval(time int64) Interval {
 }
 
 // Creates an exposure key.
-func ExposureKey(key string, intervalNumber Interval, intervalCount int32, transmissionRisk int) database.ExposureKey {
-	return database.ExposureKey{
+func ExposureKey(key string, intervalNumber Interval, intervalCount int32, transmissionRisk int) model.ExposureKey {
+	return model.ExposureKey{
 		Key:              key,
 		IntervalNumber:   int32(intervalNumber),
 		IntervalCount:    intervalCount,