mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(environment): erase overwritten fields from earlier write effects, and keep the Evidence's spellings after a load
- A later write now removes the fields it overwrites from each earlier effect, and an effect is dropped once nothing is left. Two writes that together overwrite all of an update's changes leave no read able to confirm it. - A write whose recorded outcome becomes the entity's baseline erases what earlier writes changed on that entity. Their claims on the collection go too, except a create's membership. - A reset or load knows the collection spellings of the Evidence's own state as well as the installed state's. A create after a load that dropped a collection keeps its recorded spelling instead of encoding an encoded argument twice. Spellings from a state since replaced are still forgotten. The verdicts over the 50 captures are unchanged plan by plan, and the smoke applies all 31 served write tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc5e2aadff
commit
fe8ab58483
7 files changed
+229
-53
No files matched your search
@@ -193,11 +193,12 @@ refuses to answer with.
|
||||
**Attribution.**
|
||||
|
||||
- A check exercises the write tools that changed its entity or collection
|
||||
since the previous check of it, less those a later write replaced: a create
|
||||
or delete replaces an entity's earlier writes, an update the earlier updates
|
||||
whose every changed field, sent or derived, it overwrites (an increment adds
|
||||
to its field rather than overwriting it), and a write left with no changed
|
||||
entity in a collection no longer counts there. A pass confirms them and a mismatch
|
||||
since the previous check of it, less those whose effect later writes erased.
|
||||
An update's effect is the fields it sent or changed, derived ones included.
|
||||
A later update overwrites the fields it sets (an increment adds to its field
|
||||
instead), a recorded outcome every field, and a create or delete the whole
|
||||
entity. A write with nothing left on an entity no longer counts there, nor in
|
||||
a collection where no entity it changed keeps any of its effect. A pass confirms them and a mismatch
|
||||
contradicts them.
|
||||
- A mismatch after an unmodelled write that followed those writes is checked
|
||||
only on the members they sent: such a write may explain the rest.
|
||||
@@ -267,8 +268,9 @@ recording instead.
|
||||
|
||||
A create in a collection held under more than one spelling (`acme1` and
|
||||
`%61cme1`) goes to the one the call spells verbatim, as item calls choose their
|
||||
entity; a collection keeps its spelling after its last entity is deleted, until
|
||||
a reset or load starts from the spellings of the state it installs. `data/add` refuses a document that `data/get` would write back past the
|
||||
entity; a collection keeps its spelling after its last entity is deleted. A
|
||||
reset or load knows the spellings of the Evidence's state and of the state it
|
||||
installs, never those of a state it replaced. `data/add` refuses a document that `data/get` would write back past the
|
||||
32 MiB intake limit once it adds the members a document may omit, such as
|
||||
`types` and the state Evidence id.
|
||||
|
||||
|
||||
@@ -244,3 +244,12 @@ forgets the collection spellings of the state it replaced. Both new tests fail
|
||||
on the previous head. The verdicts over the 50 captures are unchanged plan by
|
||||
plan, the in-process smoke applies all 31 served write tools, and the gates
|
||||
passed again.
|
||||
|
||||
An eighth round settled three more. A later write erases the fields of an
|
||||
earlier effect it overwrites, so two writes that together overwrite all of an
|
||||
update's changes leave it nothing to be confirmed by. A recorded outcome erases
|
||||
what earlier writes changed on its entity. A load keeps the Evidence's own
|
||||
collection spellings, so a create in a collection the loaded state does not
|
||||
hold keeps its recorded spelling. Each new test fails on the previous head. The
|
||||
verdicts over the 50 captures are unchanged plan by plan, the smoke applies all
|
||||
31 served write tools, and the gates passed again.
|
||||
@@ -65,6 +65,8 @@ export class EnvironmentStateStore {
|
||||
#started: readonly StateEntity[] = [];
|
||||
/** Every collection spelling a type's entities have been held under since the last reset or load, kept when a collection empties. */
|
||||
readonly #knownCollections = new Map<string, string[]>();
|
||||
/** The collections of the Evidence's own state, which stay known after any load. */
|
||||
#evidenceCollections: ReadonlyMap<string, readonly string[]> = new Map();
|
||||
|
||||
constructor(readonly state: EnvironmentState) {
|
||||
this.#types = new Map(
|
||||
@@ -80,6 +82,7 @@ export class EnvironmentStateStore {
|
||||
/** Restore the initial state the Evidence holds; the clock and counter start again. */
|
||||
reset(): void {
|
||||
this.#install(this.state, this.#initialBytes);
|
||||
this.#evidenceCollections = this.#typeCollections;
|
||||
this.#restart();
|
||||
}
|
||||
|
||||
@@ -133,10 +136,19 @@ export class EnvironmentStateStore {
|
||||
this.#writes = 0;
|
||||
this.#sequences.clear();
|
||||
this.#started = this.#content.entities;
|
||||
// A reset or load starts as a fresh store would, without the spellings of the state it replaced.
|
||||
// A reset or load knows the Evidence's spellings and its own, never those of a state it replaced.
|
||||
this.#knownCollections.clear();
|
||||
for (const [type, named] of this.#typeCollections)
|
||||
this.#knownCollections.set(type, [...named]);
|
||||
this.#remember(this.#evidenceCollections);
|
||||
this.#remember(this.#typeCollections);
|
||||
}
|
||||
|
||||
#remember(named: ReadonlyMap<string, readonly string[]>): void {
|
||||
for (const [type, collections] of named) {
|
||||
const known = this.#knownCollections.get(type) ?? [];
|
||||
for (const collection of collections)
|
||||
if (!known.includes(collection)) known.push(collection);
|
||||
this.#knownCollections.set(type, known);
|
||||
}
|
||||
}
|
||||
|
||||
/** The entities of a type the state held when it was last reset or loaded. */
|
||||
@@ -343,12 +355,7 @@ export class EnvironmentStateStore {
|
||||
this.#entities = entities;
|
||||
this.#collections = collections;
|
||||
this.#typeCollections = typeCollections;
|
||||
for (const [type, named] of typeCollections) {
|
||||
const known = this.#knownCollections.get(type) ?? [];
|
||||
for (const collection of named)
|
||||
if (!known.includes(collection)) known.push(collection);
|
||||
this.#knownCollections.set(type, known);
|
||||
}
|
||||
this.#remember(typeCollections);
|
||||
this.#documents = new Map(
|
||||
content.documents.map((document) => [document.tool, document]),
|
||||
);
|
||||
|
||||
@@ -550,6 +550,23 @@ describe("values the state started with", () => {
|
||||
expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books");
|
||||
});
|
||||
|
||||
it("creates in the Evidence's spelling of a collection a loaded state does not hold", () => {
|
||||
const { books, create } = twoOrgs("my%20org1", "my org1");
|
||||
const { rea_state: held } = books.store.snapshot();
|
||||
expect(
|
||||
books.store.replace({
|
||||
rea_state: {
|
||||
version: held.version,
|
||||
entities: held.entities.filter(
|
||||
(entity) => !entity.collection.includes("%20"),
|
||||
),
|
||||
documents: held.documents,
|
||||
},
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books");
|
||||
});
|
||||
|
||||
it("forgets the spellings of a loaded state once reset", () => {
|
||||
const { books, create } = twoOrgs("%61cme1", "acme1");
|
||||
const { rea_state: held } = books.store.snapshot();
|
||||
|
||||
@@ -406,12 +406,15 @@ describe("reads the served answer no longer matches", () => {
|
||||
];
|
||||
expect(gated(rows, "post_books")).toMatchObject({ served: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updates whose effect later writes erase", () => {
|
||||
const versioned = (entry: Book, version: number): Book => ({
|
||||
...entry,
|
||||
version,
|
||||
});
|
||||
|
||||
it("keeps an update confirmed when a later one rewrites only part of what it changed", () => {
|
||||
const versioned = (entry: Book, version: number): Book => ({
|
||||
...entry,
|
||||
version,
|
||||
});
|
||||
const again = versioned(renamedBook(FIRST_BOOK, "Again Book", 4), 2);
|
||||
const rows: TimedCaptureRow[] = [
|
||||
[
|
||||
@@ -437,4 +440,90 @@ describe("reads the served answer no longer matches", () => {
|
||||
checked_record_ordinals: [5],
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms no update whose changes later writes overwrote between them", () => {
|
||||
const renamed = versioned(renamedBook(FIRST_BOOK, "Again Book", 4), 2);
|
||||
const final = versioned(renamedBook(FIRST_BOOK, "Again Book", 5), 9);
|
||||
const rows: TimedCaptureRow[] = [
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[versioned(FIRST_BOOK, 1), versioned(SECOND_BOOK, 1)],
|
||||
],
|
||||
["GET", "/api/books/1", versioned(FIRST_BOOK, 1)],
|
||||
["GET", "/api/books/2", versioned(SECOND_BOOK, 1)],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
versioned(RENAMED, 2),
|
||||
200,
|
||||
{ name: "Renamed Book" },
|
||||
],
|
||||
["PUT", "/api/books/1", renamed, 200, { name: "Again Book" }],
|
||||
["POST", "/api/books/1", final, 200, { version: 9 }],
|
||||
["GET", "/api/books/1", final],
|
||||
];
|
||||
// The PUT overwrote the name, slug and clock, the POST the version: nothing of the PATCH is left to read.
|
||||
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
||||
served: false,
|
||||
checked_record_ordinals: [],
|
||||
});
|
||||
expect(gated(rows, "put_books_by_id")).toMatchObject({
|
||||
checked_record_ordinals: [6],
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms no update whose changes a recorded outcome replaced", () => {
|
||||
const described = (
|
||||
entry: Book,
|
||||
name: string,
|
||||
at: number,
|
||||
text: string,
|
||||
): Book => ({
|
||||
...renamedBook(entry, name, at),
|
||||
description: text,
|
||||
});
|
||||
const final = described(FIRST_BOOK, "Other Book", 7, "z");
|
||||
const rows: TimedCaptureRow[] = [
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[
|
||||
{ ...FIRST_BOOK, description: "d" },
|
||||
{ ...SECOND_BOOK, description: "d" },
|
||||
],
|
||||
],
|
||||
["GET", "/api/books/1", { ...FIRST_BOOK, description: "d" }],
|
||||
["GET", "/api/books/2", { ...SECOND_BOOK, description: "d" }],
|
||||
[
|
||||
"PUT",
|
||||
"/api/books/2",
|
||||
described(SECOND_BOOK, "Second Book", 3, "x"),
|
||||
200,
|
||||
{ description: "x" },
|
||||
],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
described(FIRST_BOOK, "Renamed Book", 4, "d"),
|
||||
200,
|
||||
{ name: "Renamed Book" },
|
||||
],
|
||||
// Without a body the PUT is not modelled: its recorded outcome becomes the book's values.
|
||||
["PUT", "/api/books/1", described(FIRST_BOOK, "Other Book", 5, "d")],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
described(FIRST_BOOK, "Other Book", 6, "y"),
|
||||
200,
|
||||
{ description: "y" },
|
||||
],
|
||||
["PUT", "/api/books/1", final, 200, { description: "z" }],
|
||||
["GET", "/api/books/1", final],
|
||||
];
|
||||
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
||||
served: false,
|
||||
checked_record_ordinals: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -231,6 +231,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
);
|
||||
if (result.kind === "applied" && result.entity !== undefined)
|
||||
this.#effects.supersede(this.#touched, write.tool, result);
|
||||
if (result.kind === "observed")
|
||||
this.#effects.observed(this.#touched, result.targets);
|
||||
for (const target of result.targets) {
|
||||
// A recorded outcome replaces the entity's values, not its collection's membership.
|
||||
if (result.kind === "observed") {
|
||||
|
||||
@@ -129,18 +129,27 @@ export const servedLine = (
|
||||
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
|
||||
export type TouchedTargets = Map<string, Map<string, readonly string[] | null>>;
|
||||
|
||||
/** The fields a write changed on an entity: a create or delete all of them (null), an update those it sent or derived. */
|
||||
/** The fields of a write's effect on an entity no later write has overwritten: all of them for a create or delete (null). */
|
||||
type ChangedFields = readonly string[] | null;
|
||||
|
||||
/** Whether a later write replaces an earlier one's effect: it changes the whole entity, or overwrites every field the earlier changed (an increment adds to its field instead). */
|
||||
const replaces = (
|
||||
write: { readonly whole: boolean; readonly overwrites: ReadonlySet<string> },
|
||||
earlier: ChangedFields | undefined,
|
||||
): boolean =>
|
||||
earlier !== undefined &&
|
||||
(write.whole ||
|
||||
(earlier !== null &&
|
||||
earlier.every((field) => write.overwrites.has(field))));
|
||||
/** What a later write overwrites: the whole entity, the fields it sets, or every field (a recorded outcome). */
|
||||
interface Overwrite {
|
||||
readonly whole: boolean;
|
||||
readonly fields: ReadonlySet<string> | "all";
|
||||
}
|
||||
|
||||
/** What an earlier effect keeps after a write: nothing once every field it holds is overwritten; an update leaves a create's or delete's whole effect. */
|
||||
const remaining = (
|
||||
claim: ChangedFields,
|
||||
write: Overwrite,
|
||||
): ChangedFields | undefined => {
|
||||
if (write.whole) return undefined;
|
||||
if (claim === null) return null;
|
||||
const { fields } = write;
|
||||
if (fields === "all") return undefined;
|
||||
const left = claim.filter((field) => !fields.has(field));
|
||||
return left.length > 0 ? left : undefined;
|
||||
};
|
||||
|
||||
/** Add a write's changed fields under a key: all of them once any write there changed the whole entity. */
|
||||
const record = (
|
||||
@@ -159,8 +168,9 @@ const record = (
|
||||
};
|
||||
|
||||
/**
|
||||
* What each write changed since the targets it touched were last checked, so a later write that replaces its effect
|
||||
* drops it: from the entity's target, and from its collection's once no entity it changed there is left.
|
||||
* What each write changed since the targets it touched were last checked, less what later writes overwrote, so a
|
||||
* write whose effect is gone drops out: from the entity's target, and from its collection's once no entity it changed
|
||||
* there keeps any of it.
|
||||
*/
|
||||
export class WriteEffects {
|
||||
/** Per entity target, the fields each tool changed there. */
|
||||
@@ -177,6 +187,7 @@ export class WriteEffects {
|
||||
this.#members.delete(key);
|
||||
}
|
||||
|
||||
/** A write's effect replaces what earlier writes left of theirs, then joins them. */
|
||||
supersede(
|
||||
touched: TouchedTargets,
|
||||
writer: string,
|
||||
@@ -191,34 +202,73 @@ export class WriteEffects {
|
||||
): void {
|
||||
const [entity, collection] = effect.targets as [string, string?];
|
||||
const changed = effect.changed?.fields ?? effect.fields ?? [];
|
||||
const write = {
|
||||
whole: effect.fields === undefined,
|
||||
overwrites: new Set(
|
||||
changed.filter((field) => !effect.changed?.accumulated.includes(field)),
|
||||
),
|
||||
};
|
||||
const own = this.#changed.get(entity) ?? new Map<string, ChangedFields>();
|
||||
for (const [tool, fields] of own)
|
||||
if (tool !== writer && replaces(write, fields)) {
|
||||
own.delete(tool);
|
||||
touched.get(entity)?.delete(tool);
|
||||
}
|
||||
this.#changed.set(entity, record(own, writer, write.whole, changed));
|
||||
const whole = effect.fields === undefined;
|
||||
this.#overwrite(
|
||||
touched,
|
||||
{ entity, collection, writer },
|
||||
{
|
||||
whole,
|
||||
fields: new Set(
|
||||
changed.filter(
|
||||
(field) => !effect.changed?.accumulated.includes(field),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
this.#changed.set(
|
||||
entity,
|
||||
record(this.#changed.get(entity) ?? new Map(), writer, whole, changed),
|
||||
);
|
||||
if (collection === undefined) return;
|
||||
const byTool = this.#members.get(collection) ?? new Map();
|
||||
for (const [tool, entities] of byTool) {
|
||||
if (tool === writer || !replaces(write, entities.get(entity))) continue;
|
||||
entities.delete(entity);
|
||||
if (entities.size > 0) continue;
|
||||
byTool.delete(tool);
|
||||
touched.get(collection)?.delete(tool);
|
||||
}
|
||||
byTool.set(
|
||||
writer,
|
||||
record(byTool.get(writer) ?? new Map(), entity, write.whole, changed),
|
||||
record(byTool.get(writer) ?? new Map(), entity, whole, changed),
|
||||
);
|
||||
this.#members.set(collection, byTool);
|
||||
}
|
||||
|
||||
/** A recorded outcome is the entity's new baseline: what earlier writes changed on it is gone, its membership is not. */
|
||||
observed(touched: TouchedTargets, targets: readonly string[]): void {
|
||||
const [entity, collection] = targets as [string, string?];
|
||||
this.#overwrite(
|
||||
touched,
|
||||
{ entity, collection, writer: undefined },
|
||||
{ whole: false, fields: "all" },
|
||||
);
|
||||
}
|
||||
|
||||
#overwrite(
|
||||
touched: TouchedTargets,
|
||||
at: {
|
||||
readonly entity: string;
|
||||
readonly collection: string | undefined;
|
||||
readonly writer: string | undefined;
|
||||
},
|
||||
write: Overwrite,
|
||||
): void {
|
||||
const own = this.#changed.get(at.entity);
|
||||
for (const [tool, claim] of own ?? []) {
|
||||
const left = tool === at.writer ? claim : remaining(claim, write);
|
||||
if (left !== undefined) own!.set(tool, left);
|
||||
else {
|
||||
own!.delete(tool);
|
||||
touched.get(at.entity)?.delete(tool);
|
||||
}
|
||||
}
|
||||
if (at.collection === undefined) return;
|
||||
const byTool = this.#members.get(at.collection);
|
||||
for (const [tool, entities] of byTool ?? []) {
|
||||
const claim = entities.get(at.entity);
|
||||
if (tool === at.writer || claim === undefined) continue;
|
||||
const left = remaining(claim, write);
|
||||
if (left !== undefined) entities.set(at.entity, left);
|
||||
else entities.delete(at.entity);
|
||||
if (entities.size > 0) continue;
|
||||
byTool!.delete(tool);
|
||||
touched.get(at.collection)?.delete(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityTarget = (type: string, collection: string, id: string) =>
|
||||
|
||||
Reference in new issue
Block a user