Files
kandev/apps/web/lib/state/office-task-content-sync.ts
T

443 lines
16 KiB
TypeScript

/**
* Canonical-value / in-flight-guard concurrency protocol for Office task
* title + description editing (docs/specs/office-task-content-editing/spec.md,
* section E). Framework-agnostic, module-level singleton state per task.
*
* Two structures are kept deliberately separate (see the task plan's R5-F4
* record): `canonicalByTaskField` never loses a value when a field becomes
* unguarded, while `guardByTaskField` entries are garbage-collected as soon
* as a field has neither an open editor nor an unresolved write.
*/
export type SyncField = "title" | "description";
/**
* Write-guard scope: `SyncField` for the title/description canonical-value
* protocol, plus `"task"` for the whole-task generic mutation path (status,
* priority, assignee, etc. — fields with no single canonical value). Only
* `beginWrite`/`endWrite`/`shouldRestoreAfterFailedWrite` accept the widened
* type; the canonical-value API stays on `SyncField`.
*/
export type SyncScope = SyncField | "task";
export const TASK_SCOPE: SyncScope = "task";
/** Values captured for a whole-task optimistic mutation. */
export type TaskScopeValues = Record<string, unknown>;
type CandidateKind = "refetch" | "write";
type CanonicalRecord = {
value: string;
updatedAtNs: bigint | null;
sequence: number;
};
type GuardState = {
editorOpen: boolean;
pendingWrites: Set<number>;
};
type TaskScopeWrite = {
patch: TaskScopeValues;
};
type TaskScopeState = {
baseline: TaskScopeValues;
writes: Map<number, TaskScopeWrite>;
outcomes: Map<number, TaskScopeValues | null>;
};
const FIELDS: SyncField[] = ["title", "description"];
const canonicalByTaskField = new Map<string, Map<SyncField, CanonicalRecord>>();
const guardByTaskField = new Map<string, Map<SyncScope, GuardState>>();
const sequenceByTask = new Map<string, number>();
const lastWriteIssueSequenceByTaskField = new Map<string, Map<SyncScope, number>>();
const lastSuccessfulWriteSequenceByTaskField = new Map<string, Map<SyncScope, number>>();
const listenersByTaskField = new Map<string, Map<SyncField, Set<(value: string) => void>>>();
const taskScopeByTask = new Map<string, TaskScopeState>();
function fieldMap<K, T>(store: Map<string, Map<K, T>>, taskId: string): Map<K, T> {
let m = store.get(taskId);
if (!m) {
m = new Map();
store.set(taskId, m);
}
return m;
}
function isSyncField(scope: SyncScope): scope is SyncField {
return scope !== TASK_SCOPE;
}
/** Monotonic per-task counter; call at request-issue time (refetch or write). */
export function nextTaskSequence(taskId: string): number {
const next = (sequenceByTask.get(taskId) ?? 0) + 1;
sequenceByTask.set(taskId, next);
return next;
}
function normalizeValue(raw: string | null | undefined): string {
// AC-71: description normalises an absent value to "". Title's DTO
// declares it required, but applying the same fallback to both is harmless.
return raw ?? "";
}
const DATETIME_LAYOUT =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
/**
* Parses both `updated_at` layouts in play (AC-66): the space-separated,
* offset-bearing SQLite bind layout, and RFC3339Nano. Returns nanoseconds
* since the Unix epoch as a bigint, not `Date.parse`'s millisecond number —
* `Date.parse` truncates any fractional-second precision past milliseconds,
* which would silently defeat AC-60/62's strict ordering guarantee for two
* candidates issued within the same millisecond (exactly what a refetch
* racing a write's own response produces). The fractional-second digits are
* parsed directly rather than handed to `Date.parse`, whose input must not
* carry the space-separated form as-is since browsers disagree on how (or
* whether) to interpret it.
*/
export function parseUpdatedAt(raw: string | null | undefined): bigint | null {
if (!raw) return null;
const match = DATETIME_LAYOUT.exec(raw.trim());
if (!match) return null;
const [, year, month, day, hour, minute, second, frac = "", offset] = match;
const wholeSecondMs = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`);
if (Number.isNaN(wholeSecondMs)) return null;
const nanoDigits = `${frac.slice(1)}000000000`.slice(0, 9);
return BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(nanoDigits);
}
export function isFieldGuarded(taskId: string, field: SyncField): boolean {
const g = guardByTaskField.get(taskId)?.get(field);
return !!g && (g.editorOpen || g.pendingWrites.size > 0);
}
export function getCanonicalValue(taskId: string, field: SyncField): string | undefined {
return canonicalByTaskField.get(taskId)?.get(field)?.value;
}
function notifyField(taskId: string, field: SyncField): void {
const value = getCanonicalValue(taskId, field);
if (value === undefined) return;
const listeners = listenersByTaskField.get(taskId)?.get(field);
if (!listeners || listeners.size === 0) return;
listeners.forEach((listener) => listener(value));
}
/**
* Subscribes to a field's deferred-apply notifications (AC-27/AC-50). Fired
* whenever a candidate is accepted while the field is unguarded, and whenever
* the field transitions from guarded to unguarded. The listener receives the
* current canonical value and is responsible for its own "if the two differ"
* comparison against whatever it currently displays.
*/
export function subscribeField(
taskId: string,
field: SyncField,
listener: (value: string) => void,
): () => void {
const tm = fieldMap(listenersByTaskField, taskId);
const set = tm.get(field) ?? new Set<(value: string) => void>();
set.add(listener);
tm.set(field, set);
return () => {
set.delete(listener);
};
}
/**
* Records a candidate canonical value for a field. Implements the seed rule
* (AC-72), the parse-failure discard (AC-67), the unknown-instant-loses rule
* (AC-73), the strict-ordering discard (AC-60/AC-62), the equal-instant
* tiebreak by request-issue sequence (AC-63), and — for refetch candidates
* only — the write-predates-refetch staleness discard (AC-52). Returns
* whether the candidate was accepted. On acceptance, fires a deferred-apply
* notification immediately if the field is currently unguarded; otherwise the
* eventual guard release will notify once the guard clears (AC-50).
*/
type Candidate = {
rawValue: string | null | undefined;
updatedAtRaw: string | null | undefined;
sequence: number;
kind: CandidateKind;
};
function recordCandidate(taskId: string, field: SyncField, candidate: Candidate): boolean {
const { rawValue, updatedAtRaw, sequence, kind } = candidate;
const canonicalMap = fieldMap(canonicalByTaskField, taskId);
const current = canonicalMap.get(field);
const value = normalizeValue(rawValue);
const candidateNs = parseUpdatedAt(updatedAtRaw);
// AC-72: until a field has a recorded canonical value, this is a seed and
// every other rule below (including AC-52's staleness check) is exempted —
// there is nothing yet to be stale, out of order, or unparseable relative to.
if (current && kind === "refetch") {
const lastWriteSeq = lastWriteIssueSequenceByTaskField.get(taskId)?.get(field);
if (lastWriteSeq !== undefined && sequence < lastWriteSeq) {
return false; // AC-52
}
}
if (current) {
if (candidateNs === null) {
return false; // AC-67
}
if (current.updatedAtNs !== null) {
if (candidateNs < current.updatedAtNs) return false; // AC-60 / AC-62
if (candidateNs === current.updatedAtNs && sequence <= current.sequence) return false; // AC-63 loser
}
// Either candidateNs > current.updatedAtNs, or candidateNs === current.updatedAtNs
// with a higher sequence, or current.updatedAtNs was unparseable (AC-73).
}
canonicalMap.set(field, { value, updatedAtNs: candidateNs, sequence });
if (!isFieldGuarded(taskId, field)) {
notifyField(taskId, field);
}
return true;
}
/**
* AC-59: seeds a field's canonical value on initial page load. The first-ever
* call for a (taskId, field) is an unconditional AC-72 seed; a same-session
* revisit's call goes through `recordCandidate`'s normal ordering rules like
* any other candidate, so a fresh load can supersede a stale canonical value
* left over from an earlier visit instead of being silently dropped.
*/
export function seedInitialCanonical(
taskId: string,
field: SyncField,
value: string,
updatedAtRaw: string | null | undefined,
): void {
recordCandidate(taskId, field, {
rawValue: value,
updatedAtRaw,
sequence: nextTaskSequence(taskId),
kind: "refetch",
});
}
/** AC-49: records a non-stale refetch's value as canonical for a field. */
export function recordRefetchCandidate(
taskId: string,
field: SyncField,
rawValue: string | null | undefined,
updatedAtRaw: string | null | undefined,
sequence: number,
): boolean {
return recordCandidate(taskId, field, { rawValue, updatedAtRaw, sequence, kind: "refetch" });
}
/**
* Records a write's latest settled sequence and, for whole-task writes, its
* persisted values when supplied.
* The values are replayed in issue order after the final overlapping write
* settles, so an older failure cannot discard a newer failure's retained
* rollback baseline (or an approval-gate redirect).
*/
export function recordWriteSettled(
taskId: string,
scope: SyncScope,
sequence: number,
settledValues?: TaskScopeValues,
): void {
const fm = fieldMap(lastSuccessfulWriteSequenceByTaskField, taskId);
const current = fm.get(scope);
if (current === undefined || sequence > current) {
fm.set(scope, sequence);
}
if (scope !== TASK_SCOPE) return;
const state = taskScopeByTask.get(taskId);
if (!state) return;
const write = state.writes.get(sequence);
state.outcomes.set(sequence, settledValues ? { ...settledValues } : { ...write?.patch });
}
/** Records a whole-task write that did not persist any values. */
export function recordWriteFailed(taskId: string, scope: SyncScope, sequence: number): void {
if (scope === TASK_SCOPE) {
const state = taskScopeByTask.get(taskId);
if (state) state.outcomes.set(sequence, null);
}
}
/**
* AC-58: records a successful write's own response value as canonical.
* Must be called before the caller releases the write's guard contribution
* via `endWrite` (R5-F3: a failed write must never call this).
*/
export function recordWriteSuccess(
taskId: string,
field: SyncField,
rawValue: string | null | undefined,
updatedAtRaw: string | null | undefined,
sequence: number,
): boolean {
recordWriteSettled(taskId, field, sequence);
return recordCandidate(taskId, field, { rawValue, updatedAtRaw, sequence, kind: "write" });
}
/**
* AC-44/AC-53: decides whether a failed write should restore the field's
* canonical value. Must be called before `endWrite` removes this write's own
* sequence from the in-flight set. Restore is suppressed when a *later*
* (higher-sequence) commit to the same field has already succeeded (AC-44) or
* is still unresolved (AC-53) — an earlier-sequenced commit in either state
* does not suppress it (R5-F6).
*/
export function shouldRestoreAfterFailedWrite(
taskId: string,
scope: SyncScope,
sequence: number,
): boolean {
const lastSuccess = lastSuccessfulWriteSequenceByTaskField.get(taskId)?.get(scope);
if (lastSuccess !== undefined && lastSuccess > sequence) return false; // AC-44
const pending = guardByTaskField.get(taskId)?.get(scope)?.pendingWrites;
if (pending) {
for (const pendingSeq of pending) {
if (pendingSeq > sequence) return false; // AC-53
}
}
return true;
}
function releaseIfUnguarded(
taskId: string,
scope: SyncScope,
gm: Map<SyncScope, GuardState>,
guard: GuardState,
): void {
if (!guard.editorOpen && guard.pendingWrites.size === 0) {
gm.delete(scope);
if (isSyncField(scope)) {
notifyField(taskId, scope);
}
if (gm.size === 0) {
guardByTaskField.delete(taskId);
}
}
}
/** AC-28/AC-38: an open editor is one of the two guard facts. */
export function openFieldEditor(taskId: string, field: SyncField): void {
const gm = fieldMap(guardByTaskField, taskId);
const guard = gm.get(field) ?? { editorOpen: false, pendingWrites: new Set<number>() };
guard.editorOpen = true;
gm.set(field, guard);
}
/** Closing the editor by any route (commit, cancel, blur) ends its guard contribution. */
export function closeFieldEditor(taskId: string, field: SyncField): void {
const gm = guardByTaskField.get(taskId);
const guard = gm?.get(field);
if (!gm || !guard) return;
guard.editorOpen = false;
releaseIfUnguarded(taskId, field, gm, guard);
}
/**
* Registers an in-flight write's guard contribution and records this write
* as the field's most-recently-issued write for AC-52 staleness comparisons.
*/
export function beginWrite(
taskId: string,
scope: SyncScope,
sequence: number,
before?: TaskScopeValues,
patch?: TaskScopeValues,
): void {
const gm = fieldMap(guardByTaskField, taskId);
const guard = gm.get(scope) ?? { editorOpen: false, pendingWrites: new Set<number>() };
guard.pendingWrites.add(sequence);
gm.set(scope, guard);
fieldMap(lastWriteIssueSequenceByTaskField, taskId).set(scope, sequence);
if (scope !== TASK_SCOPE || !before || !patch) return;
const state = taskScopeByTask.get(taskId) ?? {
baseline: {},
writes: new Map<number, TaskScopeWrite>(),
outcomes: new Map<number, TaskScopeValues | null>(),
};
for (const key of Object.keys(patch)) {
if (!Object.prototype.hasOwnProperty.call(state.baseline, key)) {
state.baseline[key] = before[key];
}
}
state.writes.set(sequence, { patch: { ...patch } });
taskScopeByTask.set(taskId, state);
}
function reconcileTaskScope(taskId: string): TaskScopeValues | undefined {
const state = taskScopeByTask.get(taskId);
if (!state) return undefined;
taskScopeByTask.delete(taskId);
if (state.writes.size < 2) return undefined;
const reconciliation = { ...state.baseline };
const settledSequences = [...state.outcomes.keys()].sort((a, b) => a - b);
for (const settledSequence of settledSequences) {
const outcome = state.outcomes.get(settledSequence);
if (outcome) Object.assign(reconciliation, outcome);
}
return reconciliation;
}
function clearTaskScopeSequenceBookkeeping(taskId: string): void {
const issued = lastWriteIssueSequenceByTaskField.get(taskId);
issued?.delete(TASK_SCOPE);
if (issued?.size === 0) lastWriteIssueSequenceByTaskField.delete(taskId);
const settled = lastSuccessfulWriteSequenceByTaskField.get(taskId);
settled?.delete(TASK_SCOPE);
if (settled?.size === 0) lastSuccessfulWriteSequenceByTaskField.delete(taskId);
}
/** AC-70: ends a write's guard contribution identically whether it succeeded or failed. */
export function endWrite(
taskId: string,
scope: SyncScope,
sequence: number,
): TaskScopeValues | undefined {
const gm = guardByTaskField.get(taskId);
const guard = gm?.get(scope);
if (!gm || !guard) return undefined;
guard.pendingWrites.delete(sequence);
const taskScopeSettled = scope === TASK_SCOPE && guard.pendingWrites.size === 0;
const reconciliation = taskScopeSettled ? reconcileTaskScope(taskId) : undefined;
if (taskScopeSettled) {
clearTaskScopeSequenceBookkeeping(taskId);
}
releaseIfUnguarded(taskId, scope, gm, guard);
return reconciliation;
}
/**
* AC-68: ends the open-editor contribution for both fields on unmount /
* route change. Deliberately leaves `pendingWriteCount` untouched — a write
* issued before unmount must still resolve and record (AC-69).
*/
export function endAllEditorsForTask(taskId: string): void {
const gm = guardByTaskField.get(taskId);
if (!gm) return;
for (const field of FIELDS) {
const guard = gm.get(field);
if (guard?.editorOpen) {
guard.editorOpen = false;
releaseIfUnguarded(taskId, field, gm, guard);
}
}
}
/** Test-only: clears all module state between test cases. */
export function __resetOfficeTaskContentSyncForTests(): void {
canonicalByTaskField.clear();
guardByTaskField.clear();
sequenceByTask.clear();
lastWriteIssueSequenceByTaskField.clear();
lastSuccessfulWriteSequenceByTaskField.clear();
listenersByTaskField.clear();
taskScopeByTask.clear();
}