feat(typescript): Update ContentCache interface for Contract v1

- Update ContentCache interface to match Contract v1 exactly
- Add Contract v1 fields: fetchTime, mediaUrl, title, body, metadata
- Mark legacy fields as deprecated (fetchedAt, payload, meta)
- Add src/utils/contract-helpers.ts with contract validation utilities

BREAKING CHANGE: ContentCache interface now uses Contract v1 field names.
Legacy fields marked as deprecated for backward compatibility.
This commit is contained in:
Matthew Raymer
2025-11-12 11:23:58 +00:00
parent 57d1b1236c
commit b9f9f1c4d2
2 changed files with 134 additions and 6 deletions

View File

@@ -345,16 +345,43 @@ export interface CreateScheduleInput {
* Content cache entry with TTL * Content cache entry with TTL
* Stores prefetched content for offline-first display * Stores prefetched content for offline-first display
*/ */
/**
* Content cache entry (Contract v1)
*
* This interface matches Contract v1 NotificationContent structure.
* Field names align with Contract v1 to ensure cross-platform compatibility.
*
* @contract v1 - DO NOT MODIFY without Contract Change Proposal (CCP)
* @see contracts/NotificationContract.v1.ts
*/
export interface ContentCache { export interface ContentCache {
/** Unique cache identifier */ /** Unique cache identifier */
id: string; id: string;
/** Timestamp when content was fetched (milliseconds since epoch) */ /** Notification title (extracted from payload) */
fetchedAt: number; title: string;
/** Notification body text (optional, extracted from payload) */
body?: string;
/** When notification should be displayed (epoch ms, optional) */
scheduledTime?: number;
/** When content was fetched (epoch ms, Contract v1 - was fetchedAt) */
fetchTime: number;
/** Optional image URL for rich notifications (Contract v1 - was url) */
mediaUrl?: string;
/** Time-to-live in seconds */ /** Time-to-live in seconds */
ttlSeconds: number; ttlSeconds?: number;
/** Content payload (JSON string or base64 encoded) */ /** Deduplication key (for idempotency) */
payload: string; dedupeKey?: string;
/** Optional metadata */ /** Notification priority level */
priority?: 'min' | 'low' | 'default' | 'high' | 'max';
/** Additional metadata (structured, was payload string) */
metadata?: Record<string, unknown>;
// Legacy fields (deprecated, may be present for backward compatibility)
/** @deprecated Use fetchTime instead */
fetchedAt?: number;
/** @deprecated Use title, body, metadata instead */
payload?: string;
/** @deprecated Use metadata instead */
meta?: string; meta?: string;
} }

View File

@@ -0,0 +1,101 @@
/**
* contract-helpers.ts
*
* Helper utilities for Contract v1 validation and time conversion
*
* @author Matthew Raymer
* @version 1.0.0
* @contract v1
*/
import type { NotificationContent } from '../contracts/NotificationContract.v1';
/**
* Get current time in epoch milliseconds
*
* @returns Current time in epoch ms (Contract v1)
*/
export function nowMs(): number {
return Date.now();
}
/**
* Validate NotificationContent against Contract v1
*
* @param content NotificationContent to validate
* @param contractVersion Contract version (default: "v1")
* @returns true if valid, throws error if invalid
*/
export function assertContract(
content: NotificationContent,
contractVersion: string = 'v1'
): boolean {
if (contractVersion !== 'v1') {
throw new Error(`Unsupported contract version: ${contractVersion}`);
}
// Required fields
if (!content.id || typeof content.id !== 'string') {
throw new Error('Contract v1 violation: id must be a non-empty string');
}
if (!content.title || typeof content.title !== 'string') {
throw new Error('Contract v1 violation: title must be a non-empty string');
}
if (typeof content.fetchTime !== 'number' || content.fetchTime <= 0) {
throw new Error('Contract v1 violation: fetchTime must be a positive number (epoch ms)');
}
// Optional fields type checks
if (content.body !== undefined && typeof content.body !== 'string') {
throw new Error('Contract v1 violation: body must be a string if provided');
}
if (content.scheduledTime !== undefined) {
if (typeof content.scheduledTime !== 'number' || content.scheduledTime <= 0) {
throw new Error('Contract v1 violation: scheduledTime must be a positive number (epoch ms) if provided');
}
if (content.scheduledTime < content.fetchTime) {
throw new Error('Contract v1 violation: scheduledTime must be >= fetchTime');
}
}
if (content.mediaUrl !== undefined && typeof content.mediaUrl !== 'string') {
throw new Error('Contract v1 violation: mediaUrl must be a string if provided');
}
if (content.ttlSeconds !== undefined) {
if (typeof content.ttlSeconds !== 'number' || content.ttlSeconds <= 0) {
throw new Error('Contract v1 violation: ttlSeconds must be a positive number if provided');
}
}
if (content.priority !== undefined) {
const validPriorities = ['min', 'low', 'default', 'high', 'max'];
if (!validPriorities.includes(content.priority)) {
throw new Error(`Contract v1 violation: priority must be one of: ${validPriorities.join(', ')}`);
}
}
return true;
}
/**
* Get Contract v1 hash from bundled file
*
* @returns Contract hash string
*/
export async function getContractHash(): Promise<string> {
try {
const response = await fetch('/contracts/notification.v1.hash');
if (!response.ok) {
throw new Error(`Failed to load contract hash: ${response.statusText}`);
}
return (await response.text()).trim();
} catch (error) {
console.warn('Could not load contract hash:', error);
return '';
}
}