* feat(environment): take created entities' read-backs and re-derived update members - A create's observation takes the fields the first read of its entity shows that the answer lacks, and an item read's clock spellings, when the read agrees with the answer otherwise. The plan records the read (observation.read with its shape); an item read then observes rather than checks, a list read only adds fields and still checks. Serving merges the same recorded objects. - An update member sent back as the clock the entity held and stored as a fresh clock near the call is a clock rule; one stored as the slug of another member stored as sent is a slug rule. Both are accepted with any value, as whole-object writes send what they read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): record the write-views verification Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): reconcile write views without hiding contradictions * fix(environment): reject incompatible slug-only saves --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import type { JsonValue } from "../jsonValue.js";
|
|
import { overlapsWrite } from "./environmentStateWriteAttribution.js";
|
|
import {
|
|
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(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(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(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;
|
|
});
|
|
};
|