273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import type { JsonValue } from "../jsonValue.js";
|
|
import { overlapsWrite } from "./environmentStateWriteAttribution.js";
|
|
import {
|
|
ownMember,
|
|
at,
|
|
collectionPathOf,
|
|
isObject,
|
|
lastSegmentValue,
|
|
listItems,
|
|
pointerTo,
|
|
projectsFields,
|
|
scalarText,
|
|
type JsonObject,
|
|
type StateEntity,
|
|
type StateRecordFacts,
|
|
type StateView,
|
|
} from "./environmentStateRules.js";
|
|
import { queryValues, replayReason } from "./environmentStateVariants.js";
|
|
import { readClock } from "./environmentStateWriteValues.js";
|
|
|
|
/** A read a create's observation takes fields from: an item read's respell the response's clocks and add fields, a list read's only add fields. */
|
|
export interface ObservedRead {
|
|
readonly ordinal: number;
|
|
readonly pointer: string;
|
|
readonly shape: "item" | "list";
|
|
}
|
|
|
|
/** Whether two clocks agree at the coarser recorded fraction precision, retaining all digits through nanoseconds. */
|
|
export const respelledInstant = (
|
|
left: JsonValue | undefined,
|
|
right: JsonValue | undefined,
|
|
): boolean => {
|
|
const [a, b] = [readClock(left), readClock(right)];
|
|
if (a?.format.kind !== "iso" || b?.format.kind !== "iso") return false;
|
|
const [fine, coarse] =
|
|
a.format.fraction >= b.format.fraction ? [a, b] : [b, a];
|
|
if (coarse.format.kind !== "iso") return false;
|
|
const unit = 10 ** (9 - coarse.format.fraction);
|
|
const fraction = (clock: typeof fine) =>
|
|
(((clock.ms % 1000) + 1000) % 1000) * 1_000_000 + clock.subMillisecond;
|
|
return (
|
|
Math.floor(fine.ms / 1000) === Math.floor(coarse.ms / 1000) &&
|
|
Math.floor(fraction(fine) / unit) * unit === fraction(coarse)
|
|
);
|
|
};
|
|
|
|
/** A create's response object with a read's fields: an item read's values win, a list read only adds fields the response lacks. */
|
|
export const withRead = (
|
|
response: JsonObject,
|
|
read: JsonObject,
|
|
shape: ObservedRead["shape"],
|
|
): JsonObject =>
|
|
shape === "item" ? { ...response, ...read } : { ...read, ...response };
|
|
|
|
/** Where an observed entity object was found: the record and a JSON pointer into its response. */
|
|
export interface Observed {
|
|
readonly ordinal: number;
|
|
readonly pointer: string;
|
|
readonly object: JsonObject;
|
|
readonly id: string;
|
|
/** A create's later read whose fields the object adds to the response's, and whose clock spellings an item read lends it. */
|
|
readonly read?: ObservedRead;
|
|
}
|
|
|
|
/** Objects a response carries where an entity may be, in precedence tiers: the root, one member, then the element of a one-element array at the root or in one member. */
|
|
const entitySlots = (body: JsonValue): [string, JsonValue][][] => {
|
|
const members = isObject(body) ? Object.entries(body) : [];
|
|
const elements: [string, JsonValue][] =
|
|
Array.isArray(body) && body.length === 1 ? [["/0", body[0]!]] : [];
|
|
for (const [name, item] of members)
|
|
if (Array.isArray(item) && item.length === 1)
|
|
elements.push([`${pointerTo(name)}/0`, item[0]!]);
|
|
return [
|
|
[["", body]],
|
|
members.map(([name, item]): [string, JsonValue] => [pointerTo(name), item]),
|
|
elements,
|
|
];
|
|
};
|
|
|
|
/** The entity object a response carries with an identity value the state does not hold yet: the only one in the first tier that has one. */
|
|
export const newEntityIn = (
|
|
ordinal: number,
|
|
body: JsonValue | undefined,
|
|
idField: string,
|
|
held: (id: string) => boolean,
|
|
): Observed | undefined => {
|
|
if (body === undefined) return undefined;
|
|
for (const tier of entitySlots(body)) {
|
|
const found = tier.flatMap(([pointer, object]) => {
|
|
const id = isObject(object)
|
|
? scalarText(ownMember(object, idField))
|
|
: undefined;
|
|
// An object holding only the identity names the entity without showing it.
|
|
return isObject(object) &&
|
|
id !== undefined &&
|
|
!held(id) &&
|
|
Object.keys(object).some((key) => key !== idField)
|
|
? [{ ordinal, pointer, object, id }]
|
|
: [];
|
|
});
|
|
if (found.length > 0) return found.length === 1 ? found[0] : undefined;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
/** The fields an item or list view shows; undefined for any other view. */
|
|
export const viewFields = (
|
|
view: StateView | undefined,
|
|
): readonly string[] | undefined =>
|
|
view?.shape === "item" || view?.shape === "list" ? view.fields : undefined;
|
|
|
|
/** The fields of the view a read observed an entity with, when that view had shown the entity before. */
|
|
export const shownBefore = (
|
|
viewOf: (ordinal: number) => StateView | undefined,
|
|
entity: Pick<StateEntity, "record_ordinals">,
|
|
ordinal: number,
|
|
): readonly string[] => {
|
|
const view = viewOf(ordinal);
|
|
const shown = entity.record_ordinals.some(
|
|
(earlier) => viewOf(earlier)?.tool === view?.tool,
|
|
);
|
|
return (shown ? viewFields(view) : undefined) ?? [];
|
|
};
|
|
|
|
/** What a forward search for an observation reads: records after a position, read views and response bodies. */
|
|
export interface ObservationSearch {
|
|
readonly records: readonly StateRecordFacts[];
|
|
readonly from: number;
|
|
/** A timed write's completion. A read begun while it still ran may show the prior state. */
|
|
readonly after?: number;
|
|
readonly viewOf: (ordinal: number) => StateView | undefined;
|
|
readonly withBody: <T>(
|
|
ordinal: number,
|
|
use: (body: JsonValue | undefined) => T,
|
|
) => T;
|
|
readonly queryOf: (ordinal: number) => string | null | undefined;
|
|
}
|
|
|
|
/** Whether a successful read neither checks the state nor observes a write: serving replays it, or it asked for some fields only and shows no whole entity. */
|
|
export const projectedRead = (
|
|
view: StateView,
|
|
record: StateRecordFacts,
|
|
io: Pick<ObservationSearch, "withBody" | "queryOf">,
|
|
): boolean => {
|
|
if (view.shape !== "item" && view.shape !== "list") return false;
|
|
// An absence or error holds no entity, so it is never a partial one and stays evidence.
|
|
if (!succeeded(record)) return false;
|
|
if (replayReason(view, queryValues(record.query), io.queryOf) !== undefined)
|
|
return true;
|
|
// Serving answers such a selection with the whole entity, which part of one can neither confirm nor check.
|
|
if (!projectsFields(record.query)) return false;
|
|
return io.withBody(record.ordinal, (body) => {
|
|
const objects =
|
|
body === undefined || body === null
|
|
? []
|
|
: view.shape === "item"
|
|
? [at(body, view.pointer)]
|
|
: (listItems(body, view.pointer) ?? []);
|
|
return objects.some(
|
|
(object) =>
|
|
!isObject(object) ||
|
|
view.required_fields.some((field) => !Object.hasOwn(object, field)),
|
|
);
|
|
});
|
|
};
|
|
|
|
const succeeded = (record: StateRecordFacts): boolean =>
|
|
record.status !== null && record.status >= 200 && record.status < 300;
|
|
|
|
/**
|
|
* The first later read, before the next write, that shows a written entity: an item read of
|
|
* `id` when given, else an item read of an unheld id or a collection read listing exactly one.
|
|
* A read begun while the write still ran may show the earlier state, so it is not chosen.
|
|
*/
|
|
export const laterObservation = (
|
|
search: ObservationSearch,
|
|
target: {
|
|
readonly type: string;
|
|
readonly collection: string;
|
|
readonly idField: string;
|
|
readonly id?: string;
|
|
readonly held: (id: string) => boolean;
|
|
readonly isWrite: (record: StateRecordFacts) => boolean;
|
|
/** An item read matches by this instead of equal collection strings. */
|
|
readonly sameItem?: (record: StateRecordFacts) => boolean;
|
|
/** Skip read views that do not cover the facts needed by this observation. */
|
|
readonly covers?: (
|
|
view: Extract<StateView, { readonly shape: "item" | "list" }>,
|
|
) => boolean;
|
|
},
|
|
): Observed | undefined => {
|
|
for (const record of search.records.slice(search.from + 1)) {
|
|
if (target.isWrite(record)) return undefined;
|
|
// The same overlap the check path ignores: the write was still running when this read began.
|
|
if (search.after !== undefined && overlapsWrite(search.after, record.start))
|
|
continue;
|
|
const view = search.viewOf(record.ordinal);
|
|
if (
|
|
!succeeded(record) ||
|
|
(view?.shape !== "item" && view?.shape !== "list") ||
|
|
view.type !== target.type ||
|
|
projectedRead(view, record, search)
|
|
)
|
|
continue;
|
|
if (target.covers !== undefined && !target.covers(view)) continue;
|
|
const found =
|
|
view.shape === "item"
|
|
? itemObservation(search, record, view.pointer, target)
|
|
: listObservation(search, record, view.pointer, target);
|
|
if (found !== undefined) return found;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const itemObservation = (
|
|
search: ObservationSearch,
|
|
record: StateRecordFacts,
|
|
pointer: string,
|
|
target: Parameters<typeof laterObservation>[1],
|
|
): Observed | undefined => {
|
|
const pathId = lastSegmentValue(record.path);
|
|
const named =
|
|
target.sameItem?.(record) ??
|
|
(collectionPathOf(record, true) === target.collection &&
|
|
(target.id === undefined ? !target.held(pathId) : pathId === target.id));
|
|
if (!named) return undefined;
|
|
const id = target.id ?? pathId;
|
|
return search.withBody(record.ordinal, (body) => {
|
|
const object =
|
|
body === undefined
|
|
? undefined
|
|
: entitySlots(body)
|
|
.flat()
|
|
.find(([at]) => at === pointer)?.[1];
|
|
return isObject(object) &&
|
|
scalarText(ownMember(object, target.idField)) === id
|
|
? { ordinal: record.ordinal, pointer, object, id }
|
|
: undefined;
|
|
});
|
|
};
|
|
|
|
const listObservation = (
|
|
search: ObservationSearch,
|
|
record: StateRecordFacts,
|
|
pointer: string,
|
|
target: Parameters<typeof laterObservation>[1],
|
|
): Observed | undefined => {
|
|
if (collectionPathOf(record, false) !== target.collection) return undefined;
|
|
return search.withBody(record.ordinal, (body) => {
|
|
const items = body === undefined ? undefined : listItems(body, pointer);
|
|
const unheld = (items ?? [])
|
|
.map((object, index) => ({
|
|
object,
|
|
index,
|
|
id: scalarText(ownMember(object, target.idField)),
|
|
}))
|
|
.filter(
|
|
(entry): entry is { object: JsonObject; index: number; id: string } =>
|
|
entry.id !== undefined &&
|
|
!target.held(entry.id) &&
|
|
(target.id === undefined || entry.id === target.id),
|
|
);
|
|
return unheld.length === 1
|
|
? {
|
|
ordinal: record.ordinal,
|
|
pointer: `${pointer}/${String(unheld[0]!.index)}`,
|
|
object: unheld[0]!.object,
|
|
id: unheld[0]!.id,
|
|
}
|
|
: undefined;
|
|
});
|
|
};
|