import { addDays, addHours, addMinutes, addSeconds, subDays, subHours, subMinutes, subSeconds } from 'date-fns' import type { Applog, Attribute, CidString, EntityID, Timestamp } from '../applog/datom-types.ts' import { isoDateStrCompare } from '../applog/applog-utils.ts' import type { Thread } from '../thread/basic.ts' import type { WriteableThread } from '../thread/writeable.ts' /** * An EpochSnapshot maps every {@link Attribute} present in a time window to the * array of {@link Applog}s (from any entity) that carry that attribute. * * This is **not** a flat array — logs are grouped by attribute first so that * consumers can answer questions like "which entities had their `movie/title` * updated in this window?" without re-filtering. * * @example * ```typescript * const snap = createEpochSnapshot(thread, { * start: '2024-01-01T00:00:00.000Z', * end: '2024-06-01T00:00:00.000Z', * }) * for (const [attr, logs] of snap) { * console.log(`${attr}: ${logs.length} logs`) * } * ``` */ export type EpochSnapshot = ReadonlyMap /** * Options for {@link createEpochSnapshot}. */ export interface EpochSnapshotOptions { /** * Start of the time window (inclusive). ISO 8601 string matching the * {@link Timestamp} format used on every {@link Applog}. */ readonly start: Timestamp /** * End of the time window (exclusive). ISO 8601 string. * Logs whose `ts` is **equal to** `end` are excluded. */ readonly end: Timestamp } /** * Creates an {@link EpochSnapshot} — a point-in-time grouping of all app logs * within `[start, end)` grouped by their attribute name. * * The function is a **one-off (snapshot) computation**: it reads the current * state of the thread and returns a frozen map. If you need reactivity, wrap * the result in a reactive derivation or subscribe to thread changes and * re-compute. * * @param thread - The thread to snapshot. * @param options - Time range bounds. * @returns A `ReadonlyMap` keyed by attribute name. Each value is the * (chronologically sorted) array of applogs whose `at` matches that * key and whose `ts` falls inside `[start, end)`. * * @throws {Error} If `start` is lexically `>=` `end` (empty range). * * @example * ```typescript * const snap = createEpochSnapshot(thread, { * start: '2024-01-01T00:00:00.000Z', * end: '2025-01-01T00:00:00.000Z', * }) * // Iterate attribute groups: * for (const [attr, logs] of snap) { * console.log(attr, logs.length) * } * ``` */ export function createEpochSnapshot( thread: Thread, options: EpochSnapshotOptions, ): EpochSnapshot { const { start, end } = options // Validate range — reject empty windows early. if (isoDateStrCompare(start, end, 'asc') >= 0) { throw new RangeError( `[createEpochSnapshot] Empty time range: start (${start}) >= end (${end})`, ) } // Phase 1: filter by time range, group by attribute. // Using a plain object for accumulation because: // - we know all keys are strings (Attribute) so no mixed-type Map key issues // - bulk construction into a ReadonlyMap at the end gives the desired API shape const groups: Record = Object.create(null) for (const log of thread.applogs) { // Half-open range: [start, end) if (log.ts < start || log.ts >= end) continue let bucket = groups[log.at as Attribute] if (!bucket) { bucket = [] groups[log.at as Attribute] = bucket } bucket.push(log) } // Phase 2: sort each group by timestamp (ascending) for deterministic output. const keys = Object.keys(groups) for (let i = 0; i < keys.length; i++) { const attr = keys[i] as Attribute groups[attr].sort((a, b) => isoDateStrCompare(a.ts, b.ts, 'asc')) } // Phase 3: freeze into a ReadonlyMap. const map = new Map() for (let i = 0; i < keys.length; i++) { const attr = keys[i] as Attribute map.set(attr, groups[attr]) } return map } // ─── Time Specification ─────────────────────────────────────────── /** * A flexible time specification used by {@link purgeHistory}. * * - `-1` (number) — the earliest log's timestamp in the thread ("beginning") * - `0` (number) — current time ("now") * - Positive number — treated as a Unix millisecond timestamp * - `'-1d'`, `'-2h'`, `'-30m'`, `'-5s'` — relative offset **before** now * - `'+1d'`, `'2h'` — relative offset **after** now (no sign = forward) * - ISO 8601 string — absolute timestamp, used as-is * - `'now'` — current time */ export type TimeSpec = number | string /** * Resolve a {@link TimeSpec} to an absolute ISO {@link Timestamp}. * * @param spec - The time specification to resolve. * @param thread - The thread (used to find earliest log when spec is `-1`). * @param now - The reference "now" for relative specs. * @returns An ISO 8601 timestamp string. */ export function resolveTimeSpec( spec: TimeSpec, thread: Thread, now: Date, ): Timestamp { if (typeof spec === 'number') { if (spec === -1) { // Beginning of thread — use earliest log's timestamp, or now if empty if (thread.applogs.length === 0) return now.toISOString() return thread.applogs[0].ts } if (spec === 0) return now.toISOString() // Treat as Unix milliseconds return new Date(spec).toISOString() } // String spec if (spec === 'now') return now.toISOString() // Relative time: -1d, -2h, -30m, +1d, 2h, 0d, etc. const relMatch = spec.match(/^([+-]?\d+)([dhms])$/) if (relMatch) { const amount = parseInt(relMatch[1], 10) const unit = relMatch[2] let result: Date if (amount < 0) { const abs = Math.abs(amount) switch (unit) { case 'd': result = subDays(now, abs); break case 'h': result = subHours(now, abs); break case 'm': result = subMinutes(now, abs); break case 's': result = subSeconds(now, abs); break default: result = now } } else { switch (unit) { case 'd': result = addDays(now, amount); break case 'h': result = addHours(now, amount); break case 'm': result = addMinutes(now, amount); break case 's': result = addSeconds(now, amount); break default: result = now } } return result.toISOString() } // Fall through — assume it's an absolute ISO string return spec } // ─── Serialization ──────────────────────────────────────────────── /** * A JSON-serializable representation of an {@link EpochSnapshot}, safe for * writing to disk, sending over the wire, or storing in IPFS. */ export interface SerializedEpochSnapshot { /** Format marker for versioning */ readonly format: 'wovin-epoch-snapshot-v1' /** The attribute whose history was captured / purged */ readonly attribute: string /** The time range this snapshot covers */ readonly timeRange: { readonly start: Timestamp; readonly end: Timestamp } /** When this snapshot was created */ readonly createdAt: Timestamp /** Total number of applogs across all groups */ readonly logCount: number /** * The grouped applogs. Each key is an attribute name; the value is the * array of applogs that carried that attribute within the time window. */ readonly groups: Record } /** * Converts an {@link EpochSnapshot} into a {@link SerializedEpochSnapshot} * that can be persisted as JSON. * * @param snapshot - The live snapshot to serialize. * @param metadata - Identifying metadata to attach. * @returns A plain, JSON-safe object. */ export function serializeEpochSnapshot( snapshot: EpochSnapshot, metadata: { /** The attribute this purge targets */ readonly attribute: string /** The time range of the snapshot */ readonly timeRange: { readonly start: Timestamp; readonly end: Timestamp } }, ): SerializedEpochSnapshot { const groups: Record = {} let logCount = 0 for (const [attr, logs] of snapshot) { groups[attr] = logs logCount += logs.length } return { format: 'wovin-epoch-snapshot-v1', attribute: metadata.attribute, timeRange: metadata.timeRange, createdAt: new Date().toISOString(), logCount, groups, } } // ─── History Purge ──────────────────────────────────────────────── /** * Options for {@link purgeHistory}. */ export interface PurgeHistoryOptions { /** * Callback invoked with the serialized snapshot **before** any logs are * purged. Use this to persist the snapshot to a file, database, IPFS, or * any other storage you choose. * * If omitted, the snapshot is created and returned in the result but not * automatically persisted anywhere. */ persistSnapshot?: (serialized: SerializedEpochSnapshot) => Promise | void /** * Override "now" for deterministic tests. Defaults to `new Date()`. */ now?: Date } /** * The result of a successful {@link purgeHistory} call. */ export interface PurgeHistoryResult { /** * The {@link EpochSnapshot} that was taken **before** the purge. * Contains all logs (across all attributes) within the time window, even * though only one attribute was purged. */ readonly snapshot: EpochSnapshot /** Number of applogs that were removed from the thread. */ readonly purgedCount: number /** Number of applogs kept as LWW winners for the purged attribute. */ readonly keptCount: number /** The resolved time range that was processed. */ readonly timeRange: { readonly start: Timestamp; readonly end: Timestamp } } /** * For a given **attribute** and **time range**: * * 1. Creates an {@link EpochSnapshot} capturing all app logs within * `[start, end)` across **all** attributes. * 2. Calls `options.persistSnapshot` with a serialized copy of the snapshot * (so you can store the pre-purge state before any data is removed). * 3. For the target attribute, groups the captured logs by entity and keeps * only the **latest** (last-write-wins) applog per `(en, at)` pair. * All older logs for that pair are purged from the thread. * * Logs for **other** attributes are never touched. * * @param thread - A writable thread to purge from. * @param attribute - Only logs carrying this attribute are candidates for * removal. Logs with other attributes are left untouched. * @param start - Start of the time window (see {@link TimeSpec}). * @param end - End of the time window, exclusive (see {@link TimeSpec}). * @param options - Optional persistence callback and time override. * @returns A {@link PurgeHistoryResult} with the snapshot and counts. * * @throws {RangeError} If the resolved `start` is not before `end`. * * @example * ```typescript * // Purge all but the latest block/content per entity from the beginning * // up to yesterday, saving a snapshot first. * const result = await purgeHistory(thread, 'block/content', -1, '-1d', { * persistSnapshot: async (snap) => { * await writeFile('purge-archive.json', JSON.stringify(snap, null, 2)) * }, * }) * console.log(`Purged ${result.purgedCount}, kept ${result.keptCount}`) * ``` */ export async function purgeHistory( thread: WriteableThread, attribute: Attribute, start: TimeSpec, end: TimeSpec, options?: PurgeHistoryOptions, ): Promise { const now = options?.now ?? new Date() // 1. Resolve time specs const resolvedStart = resolveTimeSpec(start, thread, now) const resolvedEnd = resolveTimeSpec(end, thread, now) const timeRange = { start: resolvedStart, end: resolvedEnd } // 2. Create full EpochSnapshot for the window const snapshot = createEpochSnapshot(thread, timeRange) // 3. Persist snapshot (user-supplied callback — e.g. write to file, IPFS, …) if (options?.persistSnapshot) { const serialized = serializeEpochSnapshot(snapshot, { attribute, timeRange, }) await options.persistSnapshot(serialized) } // 4. Collect CIDs to purge for the target attribute const targetLogs = snapshot.get(attribute) if (!targetLogs || targetLogs.length === 0) { return { snapshot, purgedCount: 0, keptCount: 0, timeRange } } // Group by entity ID (at is the target attribute, so en is the discriminator) const byEntity = new Map() for (const log of targetLogs) { let bucket = byEntity.get(log.en) if (!bucket) { bucket = [] byEntity.set(log.en, bucket) } bucket.push(log) } const cidsToPurge: CidString[] = [] let keptCount = 0 for (const [, logs] of byEntity) { // Logs are sorted by ts ascending (createEpochSnapshot guarantees). // The LWW winner is the LAST log. All earlier logs are stale. if (logs.length <= 1) { keptCount += logs.length continue } // Keep the latest (last in the sorted array), drop the rest const winner = logs[logs.length - 1] keptCount += 1 for (let i = 0; i < logs.length - 1; i++) { cidsToPurge.push(logs[i].cid) } } // 5. Execute purge on the thread const purgedCount = thread.purge(cidsToPurge) return { snapshot, purgedCount, keptCount, timeRange } }