diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 0ed1f35d98..e25d8215b0 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -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 diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 23d66b80e6..4b3d71a558 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -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(); /** Targets a write changed or placed since a passing list read last showed them, whose position no read has confirmed. */ readonly #moved = new Set(); + /** 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(); /** 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; diff --git a/src/domain/analysisView/environmentStateFormWrites.test.ts b/src/domain/analysisView/environmentStateFormWrites.test.ts index fbe51951ab..acfc193264 100644 --- a/src/domain/analysisView/environmentStateFormWrites.test.ts +++ b/src/domain/analysisView/environmentStateFormWrites.test.ts @@ -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, diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index 6baabc91f2..ee2e6344b2 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -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(); readonly #collections = new Map(); @@ -57,6 +63,8 @@ export class SimulatedState { readonly #named = new Map>(); /** 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(); 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, + 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(); + 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; } diff --git a/tests/fixtures/environmentStateFormWrites.ts b/tests/fixtures/environmentStateFormWrites.ts index ffd3fe1b3a..d698c8e46f 100644 --- a/tests/fixtures/environmentStateFormWrites.ts +++ b/tests/fixtures/environmentStateFormWrites.ts @@ -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 => ({