mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 21:36:13 +00:00
Merge branch 'feat/environment-write-quality' into feat/environment-later-reads
This commit is contained in:
commit
67afb9df39
5 files changed
+121
-18
No files matched your search
@@ -868,7 +868,9 @@ update that writes the field its type's creates are sorted by moves its entity
|
||||
to where the new value sorts (its own `insert`); a bulk update moves each entity
|
||||
it writes in turn, as replay applies them. When a read listing the whole
|
||||
collection in its own order, not one its query sorts, fails after an update and the type's creates are not sorted by a field
|
||||
that update writes, the view tries the collection sorted by such a field, when
|
||||
that update writes, the view tries the order serving gives when each write since
|
||||
a list in the collection's own order showed its entity moves it in turn by such a
|
||||
field, against the others' values before their own writes, when
|
||||
the entities no write has changed since a list in the collection's own order last
|
||||
showed them already follow it, as serving moves only the entities a write
|
||||
changes, and unless
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
showsPlacement,
|
||||
SimulatedState,
|
||||
type CheckOutcome,
|
||||
type PendingWrite,
|
||||
} from "./environmentStateWriteChecks.js";
|
||||
import {
|
||||
checkedLines,
|
||||
@@ -175,6 +176,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
readonly #inserted = new Map<string, string>();
|
||||
/** Targets a write changed or placed since a passing list read last showed them, whose position no read has confirmed. */
|
||||
readonly #moved = new Set<string>();
|
||||
/** The writes to those targets' entities, in order. */
|
||||
#pending: PendingWrite[] = [];
|
||||
/** Creates whose insert rule a passing list check relied on, so it is no longer changed. */
|
||||
readonly #placed = new Set<string>();
|
||||
/** The order a read showed a type's collections sorted in after an update, when no create of it showed one. */
|
||||
@@ -434,6 +437,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
const { address } = draft.plan;
|
||||
const bulk = address !== undefined && address.by !== "action";
|
||||
const apply = (body?: ParsedRequestBody): void => {
|
||||
this.#state.track();
|
||||
const context = this.#context(
|
||||
tool.name,
|
||||
body === undefined ? undefined : { ordinal: record.ordinal, body },
|
||||
@@ -488,6 +492,12 @@ export class EnvironmentStateWriteSimulation {
|
||||
return;
|
||||
}
|
||||
this.#effects.supersede(this.#touched, write.tool, result);
|
||||
if (result.entity !== undefined) {
|
||||
const { type, collection, id } = result.entity;
|
||||
const target = entityTarget(type, collection, id);
|
||||
const before = this.#state.priorFields(target);
|
||||
if (before !== undefined) this.#pending.push({ target, before });
|
||||
}
|
||||
for (const target of result.targets) {
|
||||
this.#moved.add(target);
|
||||
touchTarget(this.#touched, this.#touchedSince, target, {
|
||||
@@ -725,8 +735,11 @@ export class EnvironmentStateWriteSimulation {
|
||||
outcome.pass &&
|
||||
view.shape === "list" &&
|
||||
outcome.positional === undefined
|
||||
)
|
||||
for (const target of outcome.targets) this.#moved.delete(target);
|
||||
) {
|
||||
const shown = new Set(outcome.targets);
|
||||
for (const target of shown) this.#moved.delete(target);
|
||||
this.#pending = this.#pending.filter((write) => !shown.has(write.target));
|
||||
}
|
||||
if (!outcome.pass) {
|
||||
const interfered = new Set([...exercised].filter(interferes));
|
||||
if (interfered.size === 0)
|
||||
@@ -1003,7 +1016,13 @@ export class EnvironmentStateWriteSimulation {
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const before = this.#state.arrange(view.type, line.collection, rule);
|
||||
// Serving moves each written entity in turn, against the others' values before their own writes.
|
||||
const before = this.#state.replay(
|
||||
view.type,
|
||||
line.collection,
|
||||
rule,
|
||||
this.#pending,
|
||||
);
|
||||
const outcome = this.#check(view, record);
|
||||
if (outcome?.pass === true) {
|
||||
for (const draft of creates) draft.plan.insert = rule;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SHELF_TIE_ROWS,
|
||||
SHELF_GUESS_ROWS,
|
||||
ID_SORTED_READ_ROWS,
|
||||
UNSORTED_PENDING_ROWS,
|
||||
TITLE_ORDER_ROWS,
|
||||
UNLOADED_ANSWER_ROWS,
|
||||
UNSORTED_SEED_ROWS,
|
||||
@@ -194,6 +195,12 @@ describe("collections kept sorted", () => {
|
||||
expect(planOf(SHELF_GUESS_ROWS, "put_books_by_id").served).toBe(false);
|
||||
});
|
||||
|
||||
it("learns no sort from renames whose moves, made in turn as serving makes them, do not reach the list", () => {
|
||||
const plan = planOf(UNSORTED_PENDING_ROWS, "put_folders_by_id");
|
||||
expect(plan.served).toBe(false);
|
||||
expect(plan.insert).toBeNull();
|
||||
});
|
||||
|
||||
it("learns the sort from a rename after a read its query sorts by id showed an earlier rename", () => {
|
||||
expect(planOf(ID_SORTED_READ_ROWS, "put_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
|
||||
@@ -47,6 +47,12 @@ interface RemovedTarget {
|
||||
}
|
||||
|
||||
/** The gate's copy of the state: entities in collection order and documents, changed by modelled writes. */
|
||||
/** A write since a list in collection order last showed its entity, with the values it replaced (`null` for a create). */
|
||||
export interface PendingWrite {
|
||||
readonly target: string;
|
||||
readonly before: JsonObject | null;
|
||||
}
|
||||
|
||||
export class SimulatedState {
|
||||
readonly #entities = new Map<string, StateEntity>();
|
||||
readonly #collections = new Map<string, string[]>();
|
||||
@@ -57,6 +63,8 @@ export class SimulatedState {
|
||||
readonly #named = new Map<string, Set<string>>();
|
||||
/** The order a type's collections keep their entities in, as its creates insert them. */
|
||||
#sortOf: (type: string) => InsertRule | undefined = () => undefined;
|
||||
/** The values each entity put since `track` replaced, `null` for one a put added. */
|
||||
readonly #prior = new Map<string, JsonObject | null>();
|
||||
|
||||
constructor(
|
||||
entities: readonly StateEntity[],
|
||||
@@ -112,6 +120,7 @@ export class SimulatedState {
|
||||
put(entity: StateEntity, index?: number): void {
|
||||
const key = entityTarget(entity.type, entity.collection, entity.id);
|
||||
const held = this.#entities.get(key);
|
||||
if (!this.#prior.has(key)) this.#prior.set(key, held?.fields ?? null);
|
||||
this.#entities.set(key, entity);
|
||||
if (held !== undefined) {
|
||||
this.#resort(held, entity);
|
||||
@@ -130,22 +139,67 @@ export class SimulatedState {
|
||||
this.#served.remember(entity.type, entity.collection);
|
||||
}
|
||||
|
||||
/** Put a collection's entities in the order an insert rule would have placed them, returning the order they had. */
|
||||
arrange(type: string, collection: string, rule: InsertRule): string[] {
|
||||
const target = collectionTarget(type, collection);
|
||||
const before = this.#collections.get(target) ?? [];
|
||||
const keys: string[] = [];
|
||||
for (const key of before)
|
||||
keys.splice(
|
||||
insertIndex(
|
||||
rule,
|
||||
keys.map((member) => this.#entities.get(member)!.fields),
|
||||
this.#entities.get(key)!.fields,
|
||||
),
|
||||
0,
|
||||
/** Start recording the values the next puts replace. */
|
||||
track(): void {
|
||||
this.#prior.clear();
|
||||
}
|
||||
|
||||
/** The values an entity held before its first put since `track`: `null` when that put added it, undefined when none did. */
|
||||
priorFields(key: string): JsonObject | null | undefined {
|
||||
return this.#prior.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a collection's entities in the order serving gives when each of `writes` moves its entity in turn by `rule`,
|
||||
* against the others' values before their own writes, or a create inserts it; returns the order they had.
|
||||
*/
|
||||
replay(
|
||||
type: string,
|
||||
collection: string,
|
||||
rule: Extract<InsertRule, { rule: "sorted" }>,
|
||||
writes: readonly PendingWrite[],
|
||||
): string[] {
|
||||
const before =
|
||||
this.#collections.get(collectionTarget(type, collection)) ?? [];
|
||||
const held = new Set(before);
|
||||
const ours = writes.filter((write) => held.has(write.target));
|
||||
const first = new Map<string, JsonObject | null>();
|
||||
for (const write of ours)
|
||||
if (!first.has(write.target)) first.set(write.target, write.before);
|
||||
const order = before
|
||||
.filter((key) => first.get(key) !== null)
|
||||
.map((key) => ({
|
||||
key,
|
||||
fields: first.get(key) ?? this.#entities.get(key)!.fields,
|
||||
}));
|
||||
for (const [index, { target: key, before: prior }] of ours.entries()) {
|
||||
const fields =
|
||||
ours.slice(index + 1).find((later) => later.target === key)?.before ??
|
||||
this.#entities.get(key)!.fields;
|
||||
const at = order.findIndex((member) => member.key === key);
|
||||
if ((prior === null) !== at < 0) continue;
|
||||
if (at >= 0) order.splice(at, 1);
|
||||
const moved =
|
||||
prior === null ||
|
||||
!same(ownMember(prior, rule.field), ownMember(fields, rule.field));
|
||||
order.splice(
|
||||
moved
|
||||
? insertIndex(
|
||||
rule,
|
||||
order.map((member) => member.fields),
|
||||
fields,
|
||||
)
|
||||
: at,
|
||||
0,
|
||||
{ key, fields },
|
||||
);
|
||||
}
|
||||
if (order.length === before.length)
|
||||
this.restore(
|
||||
type,
|
||||
collection,
|
||||
order.map((member) => member.key),
|
||||
);
|
||||
this.restore(type, collection, keys);
|
||||
return before;
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -550,6 +550,27 @@ export const ID_SORTED_READ_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
];
|
||||
})();
|
||||
|
||||
/** Folders listed out of title order, then two renames before a list sorted by title that moving each in turn cannot reach. */
|
||||
export const UNSORTED_PENDING_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [echo, foxtrot, bravo, delta] = [
|
||||
folder(1, "Echo"),
|
||||
folder(2, "Foxtrot"),
|
||||
folder(3, "Bravo"),
|
||||
folder(4, "Delta"),
|
||||
];
|
||||
const charlie = folder(1, "Charlie", 2);
|
||||
const alpha = folder(2, "Alpha", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [echo, foxtrot, bravo, delta]],
|
||||
["GET", "/api/folders/1", echo],
|
||||
["PUT", "/api/folders/1", charlie, 200, { title: "Charlie", version: 1 }],
|
||||
["GET", "/api/folders/1", charlie],
|
||||
["PUT", "/api/folders/2", alpha, 200, { title: "Alpha", version: 1 }],
|
||||
["GET", "/api/folders/2", alpha],
|
||||
["GET", "/api/folders", [alpha, bravo, charlie, delta]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** A user create that sends `active: false` and is answered so, whose read shows the account active. */
|
||||
export const ACTIVE_ON_READ_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const user = (id: number, name: string, active: boolean): Entity => ({
|
||||
|
||||
Reference in new issue
Block a user