Skip to main content

DATA_MODEL — bff-consumer-service

Sibling: DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · SECURITY_MODEL

Cross-cutting: 06 Data Models · Standards · NAMING

Important. This BFF has no domain database. The bulk of state lives in Memorystore (Redis). A small Cloud SQL Postgres schema (bff_consumer) holds the analytics outbox, handoff replay log, anonymous wishlist mirror, bot-score audit, idempotency cache, and inbox dedupe table. There is no tenant_id column on any owned row (the BFF is cross-tenant) and therefore no Postgres RLS policy.

1. Storage map

StoreUseWhy this store
Memorystore (Redis, Standard tier, 1 GB Phase 1)GuestSession, SearchSession, brand-peek cache, search-list cache, hotel-detail cache, facet cache, single-flight locks, idempotency cache, rate-limit token bucketsSub-ms reads, native TTL, Lua scripting for atomic cap-and-push, Redis Replication for regional HA
Cloud SQL Postgres (shared bff_consumer schema, db melmastoon-bff)outbox, inbox, handoff_replay_log, wishlist_anonymous, bot_score_log, idempotency_keys (mirrored for crash recovery)Transactional outbox with the wishlist/handoff inserts; durable audit
Pub/Sub (no storage of our own)Outbound telemetry eventsBackbone

2. ID prefix declarations

Per NAMING §ID prefixes, this service introduces:

PrefixEntityNotes
gms_GuestSessionAnonymous session cookie value
srs_SearchSessionTTL 1 h
wsh_WishlistMirrored row
bhd_BookingHandoffSingle-use, signed
mpv_MetaPageViewAppend-only
cfe_ConversionFunnelEventAppend-only

These are added to the canonical registry in the same PR that lands this bundle (per the rule in NAMING.md).

3. Postgres schema (bff_consumer.*)

3.1 outbox

CREATE TABLE bff_consumer.outbox (
id ulid PRIMARY KEY,
topic text NOT NULL,
payload jsonb NOT NULL,
headers jsonb NOT NULL,
retention_class text NOT NULL CHECK (retention_class IN ('operational','audit')),
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
last_error text
);

CREATE INDEX ix_outbox_unpublished ON bff_consumer.outbox (created_at)
WHERE published_at IS NULL;

Drained by outbox-relay worker. Rows older than 30 days with published_at IS NOT NULL are pruned nightly.

3.2 inbox

CREATE TABLE bff_consumer.inbox (
event_id ulid PRIMARY KEY,
subject text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
digest bytea NOT NULL
);

CREATE INDEX ix_inbox_received ON bff_consumer.inbox (received_at);

7-day retention.

3.3 handoff_replay_log

CREATE TABLE bff_consumer.handoff_replay_log (
id ulid PRIMARY KEY, -- bhd_<ulid>
guest_session_id text NOT NULL, -- gms_<ulid>
tenant_id uuid NOT NULL, -- target tenant
property_id uuid NOT NULL, -- target property
check_in date NOT NULL,
check_out date NOT NULL,
adults smallint NOT NULL,
children smallint NOT NULL,
rooms smallint NOT NULL,
currency char(3) NOT NULL,
locale text NOT NULL,
source_campaign jsonb,
hmac_key_id text NOT NULL,
fingerprint_hash bytea NOT NULL,
ip_hash bytea NOT NULL,
minted_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
consumed boolean NOT NULL DEFAULT false,
consumed_at timestamptz,
consumed_by text, -- bff-tenant-booking instance id
CONSTRAINT chk_dates CHECK (check_out > check_in),
CONSTRAINT chk_ttl CHECK (expires_at > minted_at AND expires_at - minted_at <= interval '30 minutes')
);

CREATE INDEX ix_handoff_consumed_at ON bff_consumer.handoff_replay_log (consumed_at) WHERE consumed = true;
CREATE INDEX ix_handoff_expires_at ON bff_consumer.handoff_replay_log (expires_at) WHERE consumed = false;
CREATE INDEX ix_handoff_session ON bff_consumer.handoff_replay_log (guest_session_id, minted_at DESC);

The consumed flag is updated by a small RPC the receiving bff-tenant-booking-service calls (POST /internal/handoff/{id}/consume); we accept this side-write because the trust boundary is the HMAC, and the flag exists only as a defense-in-depth replay guard. Rows older than 24 h are pruned by a nightly job.

3.4 wishlist_anonymous

CREATE TABLE bff_consumer.wishlist_anonymous (
id ulid PRIMARY KEY, -- wsh_<ulid>
guest_session_id text NOT NULL, -- gms_<ulid>
tenant_id uuid NOT NULL,
property_id uuid NOT NULL,
source text NOT NULL CHECK (source IN ('detail','list','map','recently-viewed')),
note text CHECK (char_length(note) <= 280),
added_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
CONSTRAINT uq_wishlist_session_property UNIQUE (guest_session_id, property_id)
);

CREATE INDEX ix_wishlist_session ON bff_consumer.wishlist_anonymous (guest_session_id);
CREATE INDEX ix_wishlist_tenant_property ON bff_consumer.wishlist_anonymous (tenant_id, property_id);

Hard-deleted on POST /session/clear. Soft-deleted (set removed_at) on DELETE /wishlist/{propertyId}; pruned after 30 days.

3.5 bot_score_log

CREATE TABLE bff_consumer.bot_score_log (
id ulid PRIMARY KEY,
guest_session_id text, -- nullable; pre-bootstrap requests have no session
fingerprint_hash bytea NOT NULL,
ip_hash bytea NOT NULL,
user_agent_hash bytea NOT NULL,
score real NOT NULL CHECK (score >= 0 AND score <= 1),
verdict text NOT NULL CHECK (verdict IN ('human','suspect','bot')),
signals jsonb NOT NULL, -- BotSignal[]
action_taken text NOT NULL CHECK (action_taken IN ('none','challenge','hard-block')),
evaluated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ix_botscore_evaluated ON bff_consumer.bot_score_log (evaluated_at);
CREATE INDEX ix_botscore_fingerprint ON bff_consumer.bot_score_log (fingerprint_hash, evaluated_at DESC);

7-day retention; insert-only.

3.6 idempotency_keys

CREATE TABLE bff_consumer.idempotency_keys (
composite_key text PRIMARY KEY, -- sha256(guest_session_id|route|key)
guest_session_id text NOT NULL,
route text NOT NULL,
request_digest bytea NOT NULL, -- sha256(canonicalized request body)
response_status smallint NOT NULL,
response_body jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL -- created_at + 24h
);

CREATE INDEX ix_idempotency_expires ON bff_consumer.idempotency_keys (expires_at);

Memorystore is the primary; Postgres is a crash-recovery mirror written in the same transaction as the protected mutation.

3.7 tenant_suspended_cache (materialized lookup)

CREATE TABLE bff_consumer.tenant_suspended_cache (
tenant_id uuid PRIMARY KEY,
suspended_at timestamptz NOT NULL,
reason text NOT NULL,
refreshed_at timestamptz NOT NULL DEFAULT now()
);

Refreshed by the tenant.suspended.v1 / tenant.reinstated.v1 consumers. Hot reads use Memorystore; this table is the durable mirror for service restarts.

4. Indexes summary

IndexSelectivity rationale
ix_outbox_unpublishedDrains hundreds of unpublished rows quickly; partial index keeps it tiny
ix_handoff_session(guest_session_id, minted_at DESC)Per-session handoff history for fraud analysis
ix_handoff_expires_at (partial)Sweeper for un-consumed expiry
ix_wishlist_sessionList wishlist by session in O(log n)
ix_wishlist_tenant_propertyFuture tenant-side analytics (most-wishlisted properties)
ix_botscore_fingerprint(..., evaluated_at DESC)Cadence detector reads recent scores per fingerprint
ix_idempotency_expiresSweeper for expired keys

5. Memorystore (Redis) keyspace

All keys are prefixed by environment ({env}:). Examples below assume prod:.

Key patternTypeTTLPurpose
prod:bff-consumer:session:{gms_id}Hash30 dGuestSession blob
prod:bff-consumer:session:{gms_id}:wishlistList (capped 100)30 dMirror of WishlistRef[]
prod:bff-consumer:session:{gms_id}:recentList (capped 50)30 dRecentlyViewedEntry[]
prod:bff-consumer:session:{gms_id}:historyList (capped 25)30 dSearchHistoryEntry[]
prod:bff-consumer:srs:{srs_id}Hash1 hSearchSession
prod:bff-consumer:cache:search:list:{queryHash}String (JSON)60 s (campaign mode 5 min)List-view VM result
prod:bff-consumer:cache:search:map:{queryHash}String (JSON)60 sMap-view pins
prod:bff-consumer:cache:detail:{propertyId}:{locale}:{currency}String (JSON)5 minHotelDetailVM
prod:bff-consumer:cache:availability:{propertyId}:{from}:{to}:{occupancy}String (JSON)60 sLight availability
prod:bff-consumer:cache:facets:{country}:{region}String (JSON)1 hFacetCatalog
prod:bff-consumer:cache:brand-peek:{tenantId}String (JSON)15 minBrandPeekVM
prod:bff-consumer:cache:property:{propertyId}String (JSON)5 minRaw PropertyDetail
prod:bff-consumer:lock:{cacheKey}String5 sSingle-flight lock
prod:bff-consumer:idem:{compositeKey}String (JSON)24 hIdempotency-Key cache
prod:bff-consumer:rl:{bucketKey}Token bucket (custom)1 minRate-limit
prod:bff-consumer:tenant-suspendedSetn/a (event-driven)In-memory suspended-tenant set
prod:bff-consumer:bot:cadence:{fingerprint}List (sliding window)60 sLast-60s request timestamps
prod:bff-consumer:bot:fp-collisions:{fingerprint}Sorted Set (sessions)24 hSessions sharing fingerprint

5.1 Atomic operations (Lua scripts)

-- session:wishlist:append
-- KEYS[1] = session wishlist list key
-- ARGV[1] = wishlistRef JSON
-- ARGV[2] = max length (100)
local len = redis.call('LLEN', KEYS[1])
if len >= tonumber(ARGV[2]) then return -1 end
redis.call('RPUSH', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], 2592000) -- 30 d
return len + 1
-- recently-viewed:push
-- Cap-and-push with FIFO eviction
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[2]) - 1)
redis.call('EXPIRE', KEYS[1], 2592000)
return redis.call('LLEN', KEYS[1])
-- single-flight:acquire
-- Returns 1 if acquired, 0 if a leader is already populating
return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2])

6. TypeScript ↔ Postgres mapping

// src/infrastructure/db/schema/handoff-replay-log.ts (Drizzle)
export const handoffReplayLog = pgTable('handoff_replay_log', {
id: text('id').primaryKey(), // bhd_<ulid>
guestSessionId: text('guest_session_id').notNull(),
tenantId: uuid('tenant_id').notNull(),
propertyId: uuid('property_id').notNull(),
checkIn: date('check_in').notNull(),
checkOut: date('check_out').notNull(),
adults: smallint('adults').notNull(),
children: smallint('children').notNull(),
rooms: smallint('rooms').notNull(),
currency: char('currency', { length: 3 }).notNull(),
locale: text('locale').notNull(),
sourceCampaign: jsonb('source_campaign'),
hmacKeyId: text('hmac_key_id').notNull(),
fingerprintHash: bytea('fingerprint_hash').notNull(),
ipHash: bytea('ip_hash').notNull(),
mintedAt: timestamp('minted_at', { withTimezone: true }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
consumed: boolean('consumed').notNull().default(false),
consumedAt: timestamp('consumed_at', { withTimezone: true }),
consumedBy: text('consumed_by'),
});

Money is not persisted in this BFF (we don't compute money). When a RateSnapshotVM is cached, the cheapestNightlyMinor and totalForStayMinor are stored as text inside the JSON blob (Drizzle JSONB) to avoid bigint truncation.

7. Migrations

drizzle-kit per service. Initial migration ordering:

OrdinalFile
00010001_init_schema.sql (CREATE SCHEMA bff_consumer)
00020002_create_outbox_inbox.sql
00030003_create_handoff_replay_log.sql
00040004_create_wishlist_anonymous.sql
00050005_create_bot_score_log.sql
00060006_create_idempotency_keys.sql
00070007_create_tenant_suspended_cache.sql

Forward-only migrations. Rollback by re-applying a corrective migration (per docs/06-data-models.md).

8. Backups + recovery

  • Cloud SQL Postgres: point-in-time recovery (PITR) enabled, 7-day window.
  • Memorystore: Redis Standard tier with auto-failover; data is best-effort. On Memorystore loss the BFF self-heals: cache rebuilds on demand, sessions are re-issued (cookie still valid, but blob is empty), wishlist is re-hydrated from wishlist_anonymous Postgres mirror, idempotency cache is re-hydrated from idempotency_keys Postgres mirror.
  • Outbox: any unpublished rows are retried on restart; no events are lost.

9. Capacity sizing

TablePhase 1 row estimateIndex footprint
outbox< 50k unpublished at peak; 2M/day produced< 200 MB
handoff_replay_log100k/day< 100 MB
wishlist_anonymous1M (Phase 1)< 50 MB
bot_score_log50k/day (only suspect+)< 50 MB
idempotency_keys100k active< 100 MB

Memorystore working set: 1 GB Phase 1, 4 GB campaign mode.

10. Open questions

QuestionOwnerTarget decision
Phase 2 authenticated upgrade — where do we persist user-linked wishlist?Frontend Platform + IAMPhase 1.5
Cross-region session replication — Redis Replication HA vs. eventual sync via Pub/SubSREPhase 1 readiness
Bot-score model versioning — store model id on each row?SecurityPhase 2
Should handoff_replay_log move to Cloud Spanner if cross-region writes become hot?SREPhase 3