205 lines
8.0 KiB
TypeScript
205 lines
8.0 KiB
TypeScript
import type { JsonValue } from "../jsonValue.js";
|
|
import {
|
|
ownMember,
|
|
isObject,
|
|
same,
|
|
type JsonObject,
|
|
type StateEntity,
|
|
} from "./environmentStateRules.js";
|
|
import type { FieldRule, WriteCall } from "./environmentStateWriteRules.js";
|
|
import {
|
|
integerOf,
|
|
readClock,
|
|
slugOf,
|
|
sortedStrings,
|
|
} from "./environmentStateWriteValues.js";
|
|
import { freshUuid, renewedRule } from "./environmentStateWriteGenerated.js";
|
|
import { composedChange } from "./environmentStateWriteComposed.js";
|
|
import { referenceRule } from "./environmentStateWriteReferences.js";
|
|
import {
|
|
derivedRule,
|
|
freshToken,
|
|
isClockNear,
|
|
nestedClockRule,
|
|
stampRule,
|
|
} from "./environmentStateWriteStamps.js";
|
|
|
|
/** What explaining an update reads: the entity before and after, the call and its method. */
|
|
export interface UpdateEvidence {
|
|
readonly before: JsonObject;
|
|
readonly after: JsonObject;
|
|
readonly call: WriteCall & { readonly body: JsonObject };
|
|
readonly method: string;
|
|
readonly idField: string;
|
|
readonly id: string;
|
|
readonly startMs: number | undefined;
|
|
/** Fields covered by a later read view. Missing fields outside that coverage remain unknown. */
|
|
readonly readFields?: readonly string[];
|
|
/** The type's other entities, none of which may hold a value the update generated. */
|
|
readonly others: readonly JsonObject[];
|
|
/** The fields of the observing read's view when that view showed the entity before, so one the entity lacked was added. */
|
|
readonly shown?: readonly string[];
|
|
/** An item action, whose unsent changes take the values it showed. */
|
|
readonly action?: boolean;
|
|
/** The entities of any type the state holds under an id, which an object the update rewrote may repeat. */
|
|
readonly named?: ((id: string) => readonly StateEntity[]) | undefined;
|
|
/** The references earlier calls showed, which a call naming the entity a field already repeats keeps. */
|
|
readonly references?: readonly Extract<FieldRule, { rule: "reference" }>[];
|
|
}
|
|
|
|
/** The rule a field the update changed without sending follows, if any; `slugs` maps each echoed member's slug to the first such member. */
|
|
export const changeRule = (
|
|
evidence: UpdateEvidence,
|
|
field: string,
|
|
slugs: ReadonlyMap<string, string>,
|
|
): FieldRule | undefined => {
|
|
const before = ownMember(evidence.before, field);
|
|
const after = ownMember(evidence.after, field)!;
|
|
if (readClock(after) !== undefined && isClockNear(after, evidence.startMs))
|
|
return { field, rule: "clock", format: readClock(after)!.format };
|
|
if (freshUuid(evidence, field)) return { field, rule: "unique" };
|
|
// A renamed slug is spelled like a token, so the slug comes first.
|
|
const member = typeof after === "string" ? slugs.get(after) : undefined;
|
|
if (member !== undefined) return { field, rule: "slug", from: member };
|
|
if (freshToken(evidence, field)) return { field, rule: "unique" };
|
|
const nested = nestedClockRule(field, { before, after }, evidence);
|
|
if (nested !== undefined) return nested;
|
|
const reference =
|
|
evidence.named === undefined
|
|
? undefined
|
|
: referenceRule(field, after, evidence.call.body, evidence.named);
|
|
if (reference !== undefined) return reference;
|
|
// Child rows only: a single UUID that changed is a reference or a generated value, judged above.
|
|
const renewed =
|
|
Array.isArray(before) || isObject(before)
|
|
? renewedRule(field, { before, after }, evidence.others)
|
|
: undefined;
|
|
if (renewed !== undefined) return renewed;
|
|
const was = integerOf(before);
|
|
if (was !== undefined && integerOf(after) === was + 1)
|
|
return { field, rule: "increment" };
|
|
return (
|
|
stampRule(evidence, field) ??
|
|
derivedRule(evidence, field) ??
|
|
composedChange(field, {
|
|
...evidence,
|
|
sent: evidence.call.body,
|
|
}) ??
|
|
actionRule(evidence, field)
|
|
);
|
|
};
|
|
|
|
/** An item action sets a scalar it names; a changed object, list or instant is another write's doing or a regenerated value. */
|
|
const actionRule = (
|
|
evidence: UpdateEvidence,
|
|
field: string,
|
|
): FieldRule | undefined => {
|
|
const after = ownMember(evidence.after, field)!;
|
|
return evidence.action === true &&
|
|
(after === null || typeof after !== "object") &&
|
|
readClock(after) === undefined
|
|
? { field, rule: "set", value: after }
|
|
: undefined;
|
|
};
|
|
|
|
/** Whether a clock stored in whole seconds names the second a call started in, where a fresh stamp would read the same. */
|
|
const inCallSecond = (
|
|
value: JsonValue | undefined,
|
|
startMs: number | undefined,
|
|
): boolean => {
|
|
const clock = readClock(value);
|
|
return (
|
|
clock !== undefined &&
|
|
startMs !== undefined &&
|
|
clock.ms % 1000 === 0 &&
|
|
clock.subMillisecond === 0 &&
|
|
Math.abs(clock.ms - startMs) < 1000
|
|
);
|
|
};
|
|
|
|
/** A sent clock the call left as it was, stored in whole seconds and naming the second the call ran in: the application stamps it anew, and the client sent back the one it read. */
|
|
export const heldClock = (
|
|
evidence: UpdateEvidence,
|
|
member: string,
|
|
sent: JsonValue,
|
|
): boolean =>
|
|
inCallSecond(sent, evidence.startMs) &&
|
|
same(ownMember(evidence.before, member), sent) &&
|
|
same(ownMember(evidence.after, member), sent);
|
|
|
|
/** A field naming when its entity was made (`createdAt`, `row_created_timestamp`), which no update stamps. */
|
|
const CREATION_CLOCK = /creat(?:ed|ion)/iu;
|
|
|
|
/** Unsent clocks an update left as they were in the second it ran, other than creation clocks: a fresh stamp would read the same, so the call cannot show whether it stamps them. */
|
|
export const sameSecondClocks = (evidence: UpdateEvidence): string[] =>
|
|
Object.keys(evidence.after).filter(
|
|
(field) =>
|
|
!CREATION_CLOCK.test(field) &&
|
|
!Object.hasOwn(evidence.call.body, field) &&
|
|
Object.hasOwn(evidence.before, field) &&
|
|
same(evidence.before[field], evidence.after[field]) &&
|
|
inCallSecond(evidence.after[field], evidence.startMs),
|
|
);
|
|
|
|
/** Another sent member stored as sent whose slug the application stored in place of what this member sent. */
|
|
const sentSlugSource = (
|
|
evidence: UpdateEvidence,
|
|
member: string,
|
|
): string | undefined => {
|
|
const stored = ownMember(evidence.after, member);
|
|
if (typeof stored !== "string") return undefined;
|
|
return Object.entries(evidence.call.body).find(
|
|
([other, value]) =>
|
|
other !== member &&
|
|
typeof value === "string" &&
|
|
same(ownMember(evidence.after, other), value) &&
|
|
slugOf(value) === stored,
|
|
)?.[0];
|
|
};
|
|
|
|
/** Whether a call sent back the clock the entity held, as a whole-object write does, and the application stamped it anew near the call. */
|
|
const restampedClock = (
|
|
evidence: UpdateEvidence,
|
|
member: string,
|
|
sent: JsonValue,
|
|
): boolean =>
|
|
!CREATION_CLOCK.test(member) &&
|
|
readClock(sent) !== undefined &&
|
|
same(ownMember(evidence.before, member), sent) &&
|
|
readClock(ownMember(evidence.after, member)) !== undefined &&
|
|
isClockNear(ownMember(evidence.after, member)!, evidence.startMs);
|
|
|
|
/** How an update stored a sent member: stamped anew as the one held clock it sent back other than a creation clock, echoed, stamped anew or slugged from another member in place of what it sent, renewed as a fresh UUID, or as a constant. */
|
|
export const sentRule = (
|
|
evidence: UpdateEvidence,
|
|
member: string,
|
|
held: readonly string[],
|
|
): FieldRule => {
|
|
const sent = ownMember(evidence.call.body, member)!;
|
|
if (held.length === 1 && held[0] === member && !CREATION_CLOCK.test(member))
|
|
return {
|
|
field: member,
|
|
rule: "held-clock",
|
|
format: readClock(sent)!.format,
|
|
};
|
|
if (same(ownMember(evidence.after, member), sent))
|
|
return { field: member, rule: "echo" };
|
|
if (same(ownMember(evidence.after, member), sortedStrings(sent)))
|
|
return { field: member, rule: "echo", sorted: true };
|
|
if (restampedClock(evidence, member, sent))
|
|
return {
|
|
field: member,
|
|
rule: "clock",
|
|
format: readClock(ownMember(evidence.after, member))!.format,
|
|
};
|
|
const from = sentSlugSource(evidence, member);
|
|
if (from !== undefined) return { field: member, rule: "slug", from };
|
|
return freshUuid(evidence, member)
|
|
? { field: member, rule: "unique" }
|
|
: {
|
|
field: member,
|
|
rule: "constant",
|
|
value: ownMember(evidence.after, member)!,
|
|
};
|
|
};
|