Stored records without timestamps or source markers make it impossible for the app to determine whether cached data is fresh, which copy won a conflict, or whether a local edit has been synced. ISO 25010:2011 reliability.recoverability requires that the system can determine the state of data after a failure — impossible without creation and modification timestamps. Without a source marker, merge logic cannot distinguish local-only edits from server-authoritative records. This undermines every higher-order pattern in this bundle: cache invalidation, conflict resolution, and sync-on-reconnect all require metadata to make correct decisions.
Low because missing metadata degrades offline correctness and merge reliability rather than enabling direct exploitation, but it undermines every other sync-safety pattern.
Add createdAt, updatedAt, source, and version fields to every data model written to local storage. Populate them at the point of save, not at the point of display.
interface StoredRecord {
id: string;
// ... domain fields ...
createdAt: number; // Unix ms
updatedAt: number; // Unix ms
source: 'local' | 'remote'; // origin of last write
version: number; // schema version for migrations
}
export async function saveRecord(
record: Omit<StoredRecord, 'createdAt' | 'updatedAt' | 'source' | 'version'>
) {
const now = Date.now();
const stored: StoredRecord = { ...record, createdAt: now, updatedAt: now, source: 'local', version: 1 };
await AsyncStorage.setItem(`record_${record.id}`, JSON.stringify(stored));
}
export async function updateRecord(id: string, patch: Partial<StoredRecord>) {
const existing = await loadRecord(id);
if (!existing) return;
await AsyncStorage.setItem(
`record_${id}`,
JSON.stringify({ ...existing, ...patch, updatedAt: Date.now() })
);
}
Review all existing AsyncStorage.setItem call sites and add the four metadata fields to any schema that stores mutable domain data.
ID: mobile-offline-storage.data-persistence.data-metadata
Severity: low
What to look for: Examine stored data structures. Look for timestamps, source information (local vs. remote), version markers, or other metadata attached to stored records that help the app understand data freshness and origin.
Pass criteria: Count all data model definitions or schema declarations. Stored data includes at least 2 metadata fields such as creation timestamp, last modified timestamp, source marker (local vs. remote), or version number. These metadata fields help the app make intelligent cache/merge decisions.
Fail criteria: Stored data has no metadata. App cannot determine if cached data is stale or distinguish between local and remote origin. No more than 0 primary data models should lack timestamp fields.
Skip (N/A) when: Never — metadata is valuable for robust offline handling.
Detail on fail: "Stored records have no timestamps. App cannot determine data freshness or decide when to refresh from server."
Remediation: Add metadata to your data model:
interface StoredRecord {
id: string;
title: string;
description: string;
// Metadata
createdAt: number; // Unix timestamp
updatedAt: number; // Unix timestamp
source: 'local' | 'remote'; // Where did this data originate?
version: number; // Track schema version for migrations
}
export async function saveRecord(record: Omit<StoredRecord, 'createdAt' | 'updatedAt' | 'source' | 'version'>) {
const now = Date.now();
const stored: StoredRecord = {
...record,
createdAt: now,
updatedAt: now,
source: 'local',
version: 1,
};
await AsyncStorage.setItem(`record_${record.id}`, JSON.stringify(stored));
}
export async function updateRecord(id: string, updates: Partial<StoredRecord>) {
const existing = await getRecord(id);
if (!existing) return;
const updated: StoredRecord = {
...existing,
...updates,
updatedAt: Date.now(),
version: existing.version,
};
await AsyncStorage.setItem(`record_${id}`, JSON.stringify(updated));
}
export async function mergeRemoteRecord(remote: any) {
const local = await getRecord(remote.id);
if (!local) {
// New remote record
const stored: StoredRecord = {
...remote,
createdAt: remote.createdAt || Date.now(),
updatedAt: remote.updatedAt || Date.now(),
source: 'remote',
version: 1,
};
await AsyncStorage.setItem(`record_${remote.id}`, JSON.stringify(stored));
return;
}
// Merge: prefer most recently updated
const merged: StoredRecord = {
...local,
...(remote.updatedAt && remote.updatedAt > local.updatedAt ? remote : {}),
updatedAt: Math.max(local.updatedAt, remote.updatedAt || 0),
source: local.source, // Keep original source tracking
version: Math.max(local.version, remote.version || 1),
};
await AsyncStorage.setItem(`record_${merged.id}`, JSON.stringify(merged));
}
// Cleanup old data based on metadata
export async function cleanupStaleData(maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
const allKeys = await AsyncStorage.getAllKeys();
const now = Date.now();
for (const key of allKeys) {
if (!key.startsWith('record_')) continue;
const data = await AsyncStorage.getItem(key);
if (!data) continue;
const record = JSON.parse(data) as StoredRecord;
if (now - record.updatedAt > maxAgeMs) {
await AsyncStorage.removeItem(key);
}
}
}