File

src/app/features/change-history/change-history-normalize.ts

Description

The raw audit document as written by the replication-backend (issue #4026) into the <db>-audit database.

Index

Properties

Properties

_id
_id: string
Type : string
database
database: string
Type : string
Optional
diff
diff: unknown
Type : unknown
Optional

For update: a jsondiffpatch delta. For baseline: the full previous document. For create: a whole-value add ([doc]). For delete: a structural delta (no displayable field pairs).

entityId
entityId: string
Type : string
operation
operation: "create" | "update" | "delete" | "baseline"
Type : "create" | "update" | "delete" | "baseline"
parentRev
parentRev: string
Type : string
Optional
rev
rev: string
Type : string
Optional
timestamp
timestamp: string
Type : string
user
user: literal type
Type : literal type
Optional
import { Delta, patch } from "jsondiffpatch";
import { isEqual } from "lodash-es";
import {
  BASELINE_NOTE,
  ChangeEvent,
  FieldChange,
  OPERATION_TO_ACTION,
} from "./change-history.types";

/**
 * The raw audit document as written by the replication-backend (issue #4026)
 * into the `<db>-audit` database.
 */
export interface RawAuditDoc {
  _id: string;
  entityId: string;
  database?: string;
  operation: "create" | "update" | "delete" | "baseline";
  rev?: string;
  parentRev?: string;
  timestamp: string;
  user?: { id?: string; name?: string; roles?: string[] };
  /**
   * For `update`: a jsondiffpatch delta. For `baseline`: the full previous
   * document. For `create`: a whole-value add (`[doc]`). For `delete`: a
   * structural delta (no displayable field pairs).
   */
  diff?: unknown;
}

/** Doc fields that are internal/metadata and never shown as user-facing field changes. */
const HIDDEN_FIELDS = new Set([
  "_id",
  "_rev",
  "_revisions",
  "created",
  "updated",
]);

function isHidden(field: string): boolean {
  return field.startsWith("_") || HIDDEN_FIELDS.has(field);
}

/**
 * Build the displayable change history for one entity from its raw audit docs.
 *
 * A jsondiffpatch update-delta only encodes *what changed* (e.g. items added to
 * an array by index), not the full prior value — so per-field before/after
 * cannot be read from a single delta. Instead the full document state is
 * replayed: starting from the `create`/`baseline` snapshot and applying each
 * update delta in chronological order, the before/after of every field
 * (including multi-value arrays) is the difference between consecutive full
 * states.
 *
 * @returns events newest-first
 */
export function buildChangeEvents(rawDocs: RawAuditDoc[]): ChangeEvent[] {
  const ordered = [...(rawDocs ?? [])].sort(byChronology);
  let state: Record<string, unknown> = {};
  const events: ChangeEvent[] = [];

  for (const doc of ordered) {
    const action = OPERATION_TO_ACTION[doc.operation] ?? "updated";
    const base = {
      id: doc._id,
      at: new Date(doc.timestamp),
      by: doc.user?.name ?? doc.user?.id ?? "",
      action,
    };

    if (doc.operation === "baseline" || doc.operation === "create") {
      const snapshot = snapshotOf(doc);
      state = { ...snapshot };
      events.push({
        ...base,
        changes: additions(snapshot),
        note: action === "baseline" ? BASELINE_NOTE : undefined,
      });
    } else if (doc.operation === "delete") {
      events.push({ ...base, changes: [] });
    } else {
      const next = applyDelta(state, doc.diff);
      events.push({ ...base, changes: fieldChanges(state, next) });
      state = next;
    }
  }

  return events.reverse();
}

/** chronological: by server timestamp, then by revision generation */
function byChronology(a: RawAuditDoc, b: RawAuditDoc): number {
  const byTime =
    new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
  return byTime !== 0 ? byTime : revGeneration(a.rev) - revGeneration(b.rev);
}

function revGeneration(rev?: string): number {
  return rev ? parseInt(rev.split("-")[0], 10) || 0 : 0;
}

/** the full document snapshot carried by a baseline (raw doc) or create (`[doc]`) record */
function snapshotOf(doc: RawAuditDoc): Record<string, unknown> {
  const full =
    doc.operation === "create" && Array.isArray(doc.diff)
      ? doc.diff[0]
      : doc.diff;
  return full && typeof full === "object"
    ? (full as Record<string, unknown>)
    : {};
}

/** apply a jsondiffpatch delta to a deep clone of the current state */
function applyDelta(
  state: Record<string, unknown>,
  delta: unknown,
): Record<string, unknown> {
  if (!delta) {
    return state;
  }
  try {
    return patch(structuredClone(state), delta as Delta) as Record<
      string,
      unknown
    >;
  } catch {
    // a delta that doesn't cleanly apply (e.g. a missing-ancestor branch) must
    // not break the whole history — keep the prior state for this step
    return state;
  }
}

/** every (non-hidden) field of a snapshot rendered as an addition */
function additions(snapshot: Record<string, unknown>): FieldChange[] {
  return Object.keys(snapshot)
    .filter((field) => !isHidden(field))
    .map((field) => ({ field, from: undefined, to: snapshot[field] }));
}

/** the (non-hidden) fields whose full value differs between two states */
function fieldChanges(
  prev: Record<string, unknown>,
  next: Record<string, unknown>,
): FieldChange[] {
  const fields = new Set([...Object.keys(prev), ...Object.keys(next)]);
  const changes: FieldChange[] = [];
  for (const field of fields) {
    if (isHidden(field)) {
      continue;
    }
    if (!isEqual(prev[field], next[field])) {
      changes.push({ field, from: prev[field], to: next[field] });
    }
  }
  return changes;
}

results matching ""

    No results matching ""