Skip to main content

DOMAIN_MODEL — analytics-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · platform anchors: docs/06 Data Models, docs/standards/NAMING

This file describes the analytics domain in pure TypeScript terms (framework-agnostic). The infrastructure shapes (BigQuery DDL, Cloud SQL DDL) live in DATA_MODEL.


1. Branded identifier types

type Branded<T, B> = T & { readonly __brand: B };

export type ProjectionId = Branded<string, 'ProjectionId'>; // prj_<ulid>
export type MetricDefinitionId = Branded<string, 'MetricDefinitionId'>; // met_<ulid>
export type DashboardId = Branded<string, 'DashboardId'>; // dsh_<ulid>
export type WidgetId = Branded<string, 'WidgetId'>; // wid_<ulid>
export type QueryId = Branded<string, 'QueryId'>; // qry_<ulid>
export type ETLJobId = Branded<string, 'ETLJobId'>; // etl_<ulid>
export type ETLRunId = Branded<string, 'ETLRunId'>; // etr_<ulid>
export type DataQualityCheckId = Branded<string, 'DataQualityCheckId'>; // dqc_<ulid>
export type DataQualityResultId = Branded<string, 'DataQualityResultId'>; // dqr_<ulid>
export type AnalyticsEventId = Branded<string, 'AnalyticsEventId'>; // = envelope.id (evt_<ulid>)

export type TenantId = Branded<string, 'TenantId'>;
export type UserId = Branded<string, 'UserId'>;
export type PropertyId = Branded<string, 'PropertyId'>;

ULID prefixes are added to the canonical registry in docs/standards/NAMING §6.


2. Supporting value objects

export interface I18nString { default: string; values: Record<string, string>; }

export type DataResidency = 'AF' | 'IN' | 'KSA' | 'EU';

export type Granularity = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';

export interface DimensionRef {
key: string; // 'property_id', 'channel', 'rate_plan_id', 'room_type_id', 'date'
type: 'string' | 'int' | 'date' | 'enum';
enumValues?: readonly string[];
display: I18nString;
}

export interface MetricUnit {
kind: 'count' | 'percent' | 'currency' | 'duration_ms' | 'ratio';
currency?: string; // ISO 4217 when kind='currency'
decimals: number;
}

export interface ParamSpec {
key: string;
type: 'string' | 'int' | 'date' | 'date_range' | 'enum';
required: boolean;
enumValues?: readonly string[];
defaultValue?: unknown;
}

export interface FreshnessSLO {
hot: boolean; // hot domains target 5 min, cold 15 min
targetMinutes: number;
alertAtMinutes: number;
}

export interface ByteCap {
perQueryMaxBytes: number; // BigQuery byte cap
perTenantDailyMaxBytes: number;
}

export interface QuerySource {
curatedTables: readonly string[]; // 'fact_reservation', 'dim_property', …
schemaVersion: number; // pin
}

export interface Provenance {
computedAt: string;
computedBy: 'etl' | 'on_demand' | 'ai_writeback';
bytesScanned: number;
slotMs: number;
warehouseJobId: string;
}

export interface AIProvenance {
model: string;
modelVersion: string;
promptHash: string;
outputHash: string;
latencyMs: number;
costUsdMicros: number;
confidence?: number;
}

3. MetricDefinition aggregate

export class MetricDefinition {
constructor(
readonly id: MetricDefinitionId,
readonly tenantId: TenantId | null, // null = platform-shared
readonly key: string, // 'reservation.occupancy_pct'
readonly version: number, // immutable per version; bump on breaking change
readonly display: I18nString,
readonly unit: MetricUnit,
readonly grain: Granularity, // smallest granularity allowed
readonly dimensions: readonly DimensionRef[],
readonly sqlTemplate: string, // {{ params.from }}, {{ params.to }}, {{ tenantId }}
readonly sourceTables: readonly string[],
readonly schemaVersion: number,
readonly params: readonly ParamSpec[],
readonly freshness: FreshnessSLO,
readonly byteCap: ByteCap,
readonly archived: boolean,
readonly createdAt: string,
readonly updatedAt: string,
readonly etag: number,
) {}

static publish(args: { /* same fields minus mutable */ }): MetricDefinition { /* invariants below */ }

archive(): MetricDefinition { /* sets archived=true, returns a new instance */ }
}

Invariants (enforced in publish()):

  • key matches ^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$ (e.g. reservation.adr).
  • version >= 1. Re-publishing the same (key, version) is rejected with MELMASTOON.ANALYTICS.METRIC_VERSION_CONFLICT.
  • sqlTemplate references exactly the params declared and uses {{ tenantId }} (binding by orchestrator, not string interpolation by callers).
  • dimensions keys must match the columns the curated tables expose at schemaVersion.
  • freshness.targetMinutes <= freshness.alertAtMinutes.

The platform ships a default set: reservation.occupancy_pct, reservation.adr, reservation.revpar, reservation.alos_nights, reservation.cancellation_rate, reservation.no_show_rate, funnel.meta_to_book_pct, channel.revenue_mix_pct, housekeeping.task_throughput, lock.action_latency_p95_ms, ai_orchestrator.acceptance_rate.


4. Projection aggregate

A projection defines a curated table's contract, source query, and refresh policy.

export class Projection {
constructor(
readonly id: ProjectionId,
readonly tenantId: null, // always platform-shared (the table is platform-wide; rows are tenant-tagged)
readonly key: string, // 'fact_reservation'
readonly schemaVersion: number, // bump on breaking change → coexistence window
readonly target: { dataset: string; table: string }, // {'analytics_curated','fact_reservation_v1'}
readonly partitioning: { field: string; type: 'DAY' | 'MONTH' | 'INGESTION_TIME' },
readonly clustering: readonly string[],
readonly sourceQuerySql: string, // MERGE … USING (…) ON …
readonly mergeKey: readonly string[],
readonly refreshPolicy: 'incremental' | 'full',
readonly windowMinutes: number, // incremental window (e.g., 1440 = last 24 h reprocess)
readonly freshness: FreshnessSLO,
readonly archived: boolean,
readonly etag: number,
) {}

static publish(args: { /* … */ }): Projection { /* invariants */ }

archive(): Projection { /* tombstone for catalog; no DDL drop */ }
}

Invariants:

  • target.table ends with _v<schemaVersion> for fact tables (fact_*_v1).
  • Partition field is one of event_ts, business_date, or BigQuery ingestion-time partitioning.
  • clustering always starts with tenant_id.
  • sourceQuerySql must produce a tenant_id column.
  • refreshPolicy='incremental' requires windowMinutes >= 5.

5. Dashboard and Widget

export type WidgetKind = 'kpi_tile' | 'time_series' | 'breakdown' | 'funnel' | 'heatmap' | 'table';

export interface WidgetSpec {
kind: WidgetKind;
metricRef?: { key: string; version: number };
queryRef?: QueryId;
filters: Record<string, unknown>;
granularity?: Granularity;
display: { titleI18n: I18nString; legend?: I18nString };
thresholds?: { warn?: number; crit?: number };
}

export class Widget {
constructor(
readonly id: WidgetId,
readonly dashboardId: DashboardId,
readonly tenantId: TenantId,
readonly spec: WidgetSpec,
readonly position: { row: number; col: number; w: number; h: number },
readonly etag: number,
) {}
}

export type DashboardScope = 'tenant' | 'property' | 'private';

export class Dashboard {
constructor(
readonly id: DashboardId,
readonly tenantId: TenantId,
readonly ownerUserId: UserId,
readonly scope: DashboardScope,
readonly propertyId: PropertyId | null, // when scope='property'
readonly nameI18n: I18nString,
readonly description: I18nString | null,
readonly widgetIds: readonly WidgetId[],
readonly sharedWith: readonly { kind: 'user' | 'role' | 'looker_studio_token'; ref: string }[],
readonly createdAt: string,
readonly updatedAt: string,
readonly etag: number,
) {}
}

Invariants:

  • scope='property' requires non-null propertyId and the owner must have access to that property.
  • A Widget referencing metricRef must use a non-archived MetricDefinition of the matching version.
  • A widget filter cannot escape tenant scope; the renderer always intersects with tenantId and propertyAccess[].

6. Query (saved query) aggregate

export class Query {
constructor(
readonly id: QueryId,
readonly tenantId: TenantId,
readonly ownerUserId: UserId,
readonly nameI18n: I18nString,
readonly source: QuerySource,
readonly sqlTemplate: string,
readonly params: readonly ParamSpec[],
readonly byteCap: ByteCap,
readonly archived: boolean,
readonly etag: number,
) {}
}

Invariants:

  • sqlTemplate is parsed at save-time; queries that reference unauthorized datasets/tables are rejected.
  • Cross-tenant joins are forbidden (validator scans for tenant_id predicates).

7. ETLJob aggregate

export type ETLStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';

export class ETLJob {
constructor(
readonly id: ETLJobId,
readonly projectionId: ProjectionId,
readonly windowFrom: string,
readonly windowTo: string,
readonly trigger: 'cron' | 'on_demand' | 'backfill' | 'event',
readonly etag: number,
) {}
}

export class ETLRun {
constructor(
readonly id: ETLRunId,
readonly jobId: ETLJobId,
readonly status: ETLStatus,
readonly bytesScanned: number,
readonly bytesWritten: number,
readonly rowsAffected: number,
readonly slotMs: number,
readonly startedAt: string | null,
readonly succeededAt: string | null,
readonly failedAt: string | null,
readonly errorCode: string | null,
readonly errorDetail: string | null,
readonly retryOfRunId: ETLRunId | null,
readonly attempt: number,
readonly etag: number,
) {}
}

State machine:

queued → running → succeeded
→ failed → (retry up to maxAttempts) queued
queued → cancelled
running → cancelled

Invariants:

  • windowFrom < windowTo.
  • A succeeded run sets succeededAt, bytesWritten >= 0, and updates the projection's last-run pointer in metadata.
  • Failed run with non-retriable error code does not enqueue a retry.

8. DataQualityCheck

export type DQKind =
| 'row_count_drift'
| 'freshness'
| 'null_rate'
| 'distinct_count'
| 'business_rule'
| 'schema_drift';

export interface DQRule {
kind: DQKind;
table: string;
column?: string;
windowMinutes?: number;
baseline?: { method: 'avg_28d' | 'p50_28d' | 'fixed'; valueOrTolerancePct: number };
expression?: string; // for business_rule
}

export class DataQualityCheck {
constructor(
readonly id: DataQualityCheckId,
readonly tenantId: null,
readonly key: string, // 'fact_reservation.row_count_drift_24h'
readonly rule: DQRule,
readonly severity: 'info' | 'warn' | 'critical',
readonly enabled: boolean,
readonly etag: number,
) {}
}

export class DataQualityResult {
constructor(
readonly id: DataQualityResultId,
readonly checkId: DataQualityCheckId,
readonly observedValue: number,
readonly expectedValue: number | null,
readonly status: 'ok' | 'warn' | 'critical' | 'error',
readonly observedAt: string,
readonly windowFrom: string | null,
readonly windowTo: string | null,
readonly bigqueryJobId: string | null,
) {}
}

A DataQualityResult with status ∈ {warn, critical} emits melmastoon.analytics.data_quality.alert.v1.


9. AnalyticsEvent (raw)

AnalyticsEvent is read-only at the domain level. We do not mutate raw rows. Its identity is the platform envelope.id. The shape is EventEnvelope<unknown> from 04 §3. The Pub/Sub-to-BigQuery sink adds:

interface AnalyticsEventLandedColumns {
ingestion_ts: string; // BigQuery insert time
topic: string; // 'melmastoon.reservation.reservation.confirmed.v1'
envelope: object; // JSON
payload: object; // JSON
tenant_id: string; // extracted for partitioning
data_residency: string;
}

10. Authorization decisions (domain-level)

export type AuthAction =
| { kind: 'view_dashboard'; dashboardId: DashboardId }
| { kind: 'edit_dashboard'; dashboardId: DashboardId }
| { kind: 'run_query'; queryId: QueryId }
| { kind: 'run_metric'; metricKey: string }
| { kind: 'manage_projection' }
| { kind: 'manage_dq' };

export interface AuthorizationDecision {
allowed: boolean;
reasonCode?: 'AUTHZ_DENIED' | 'PROPERTY_SCOPE_VIOLATION' | 'BUDGET_EXHAUSTED';
}

The application layer maps these to RBAC permissions: analytics.viewer, analytics.author, analytics.admin (see SECURITY_MODEL §2).


11. Domain errors

export class AnalyticsDomainError extends Error {
constructor(readonly code: string, message: string, readonly cause?: unknown) { super(message); }
}
export class MetricVersionConflictError extends AnalyticsDomainError {}
export class ProjectionSchemaMismatchError extends AnalyticsDomainError {}
export class QueryByteCapExceededError extends AnalyticsDomainError {}
export class CrossTenantQueryError extends AnalyticsDomainError {}
export class DashboardScopeViolationError extends AnalyticsDomainError {}
export class ConcurrentModificationError extends AnalyticsDomainError {}
export class DataQualityCriticalError extends AnalyticsDomainError {}

Codes resolve to platform error codes per API_CONTRACTS §0.4 and docs/standards/ERROR_CODES.


12. Cross-references