537496f7bb
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
327 lines
12 KiB
TypeScript
327 lines
12 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";
|
|
type CandidateKind = "refetch" | "write";
|
|
|
|
type CanonicalRecord = {
|
|
value: string;
|
|
updatedAtNs: bigint | null;
|
|
sequence: number;
|
|
};
|
|
|
|
type GuardState = {
|
|
editorOpen: boolean;
|
|
pendingWrites: Set<number>;
|
|
};
|
|
|
|
const FIELDS: SyncField[] = ["title", "description"];
|
|
|
|
const canonicalByTaskField = new Map<string, Map<SyncField, CanonicalRecord>>();
|
|
const guardByTaskField = new Map<string, Map<SyncField, GuardState>>();
|
|
const sequenceByTask = new Map<string, number>();
|
|
const lastWriteIssueSequenceByTaskField = new Map<string, Map<SyncField, number>>();
|
|
const lastSuccessfulWriteSequenceByTaskField = new Map<string, Map<SyncField, number>>();
|
|
const listenersByTaskField = new Map<string, Map<SyncField, Set<(value: string) => void>>>();
|
|
|
|
function fieldMap<T>(store: Map<string, Map<SyncField, T>>, taskId: string): Map<SyncField, T> {
|
|
let m = store.get(taskId);
|
|
if (!m) {
|
|
m = new Map();
|
|
store.set(taskId, m);
|
|
}
|
|
return m;
|
|
}
|
|
|
|
/** 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" });
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const fm = fieldMap(lastSuccessfulWriteSequenceByTaskField, taskId);
|
|
const current = fm.get(field);
|
|
if (current === undefined || sequence > current) {
|
|
fm.set(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,
|
|
field: SyncField,
|
|
sequence: number,
|
|
): boolean {
|
|
const lastSuccess = lastSuccessfulWriteSequenceByTaskField.get(taskId)?.get(field);
|
|
if (lastSuccess !== undefined && lastSuccess > sequence) return false; // AC-44
|
|
|
|
const pending = guardByTaskField.get(taskId)?.get(field)?.pendingWrites;
|
|
if (pending) {
|
|
for (const pendingSeq of pending) {
|
|
if (pendingSeq > sequence) return false; // AC-53
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function releaseIfUnguarded(
|
|
taskId: string,
|
|
field: SyncField,
|
|
gm: Map<SyncField, GuardState>,
|
|
guard: GuardState,
|
|
): void {
|
|
if (!guard.editorOpen && guard.pendingWrites.size === 0) {
|
|
gm.delete(field);
|
|
notifyField(taskId, field);
|
|
}
|
|
}
|
|
|
|
/** 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, field: SyncField, sequence: number): void {
|
|
const gm = fieldMap(guardByTaskField, taskId);
|
|
const guard = gm.get(field) ?? { editorOpen: false, pendingWrites: new Set<number>() };
|
|
guard.pendingWrites.add(sequence);
|
|
gm.set(field, guard);
|
|
fieldMap(lastWriteIssueSequenceByTaskField, taskId).set(field, sequence);
|
|
}
|
|
|
|
/** AC-70: ends a write's guard contribution identically whether it succeeded or failed. */
|
|
export function endWrite(taskId: string, field: SyncField, sequence: number): void {
|
|
const gm = guardByTaskField.get(taskId);
|
|
const guard = gm?.get(field);
|
|
if (!gm || !guard) return;
|
|
guard.pendingWrites.delete(sequence);
|
|
releaseIfUnguarded(taskId, field, gm, guard);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|