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()):
keymatches^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$(e.g.reservation.adr).version >= 1. Re-publishing the same(key, version)is rejected withMELMASTOON.ANALYTICS.METRIC_VERSION_CONFLICT.sqlTemplatereferences exactly the params declared and uses{{ tenantId }}(binding by orchestrator, not string interpolation by callers).dimensionskeys must match the columns the curated tables expose atschemaVersion.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.tableends with_v<schemaVersion>for fact tables (fact_*_v1).- Partition field is one of
event_ts,business_date, or BigQuery ingestion-time partitioning. clusteringalways starts withtenant_id.sourceQuerySqlmust produce atenant_idcolumn.refreshPolicy='incremental'requireswindowMinutes >= 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-nullpropertyIdand the owner must have access to that property.- A
WidgetreferencingmetricRefmust use a non-archivedMetricDefinitionof the matching version. - A widget filter cannot escape tenant scope; the renderer always intersects with
tenantIdandpropertyAccess[].
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:
sqlTemplateis parsed at save-time; queries that reference unauthorized datasets/tables are rejected.- Cross-tenant joins are forbidden (validator scans for
tenant_idpredicates).
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
- BigQuery DDL: DATA_MODEL §3-§4
- Frozen metric SQL examples: DATA_MODEL §6
- Use-case bindings: APPLICATION_LOGIC
- AI signal/forecast loop: AI_INTEGRATION