Nothing is overwritten
Part 02 of The Database That Remembers Why · Act I, Facts · November 8, 2026
import { Effect } from "effect";
import { correction } from "./story.ts";
export const answer = Effect.runSync(correction);
// answer.current[0].value is false.
// answer.journal[0].changes[0].fact.value is still true.The current answer is false. The earlier assertion still says true. Unlike Part 1, this version can distinguish a correction from a history in which nobody ever said Maria was trained.
We haven't made the earlier statement true. We've stopped destroying the evidence that someone made it. This is still teaching code, not Triplex's API, using the repository's Effect 4.1.1. Each block comes from the tested files linked below.
Give statements identities
Keep Part 1's triples and pattern queries. Add an identity to each assertion, and replace put with two operations:
import { Effect, Schema, Semaphore } from "effect";
import type { Fact, Pattern } from "../01/facts.ts";
export type Assertion = Fact & { readonly id: string };
export type Change<A extends Assertion = Assertion> =
| { readonly op: "assert"; readonly fact: A }
| { readonly op: "retract"; readonly id: string };
export type Transaction<A extends Assertion = Assertion> = {
readonly position: number;
readonly changes: ReadonlyArray<Change<A>>;
};
export class StoreError extends Schema.TaggedError<StoreError>()(
"StoreError",
{ message: Schema.String },
) {}An assertion introduces a statement. A retraction withdraws that particular assertion, not every fact about its entity and attribute. The ID names evidence; it is not the entity's ID. Maria keeps her entity identity when her training record changes.
A transaction groups changes under one increasing position. The generic type lets later parts attach time to assertions without rewriting this log. StoreError gives invalid transitions a typed failure channel.
Correct, don't replace
Here is the program behind the opening result:
export const original = {
id: "training:1",
entity: "worker:maria",
attribute: ":worker/trained",
value: true,
};
export const correction = Effect.gen(function* () {
const store = makeStore();
yield* store.transact([{ op: "assert", fact: original }]);
yield* store.transact([
{ op: "retract", id: original.id },
{
op: "assert",
fact: {
...original,
id: "training:2",
value: false,
},
},
]);
return {
current: yield* store.match({ entity: "worker:maria" }),
journal: yield* store.journal,
};
});
// current has training:2, value false.
// journal positions are [1, 2].
// Position 1 still asserts training:1, value true.Position 1 asserts training:1. Position 2 retracts it and asserts training:2, whose value is false. Those last two operations belong together. A reader must not see the withdrawal without the replacement.
The journal keeps both transactions. The current answer is a projection of that journal, not a second history we edit independently:
export const replay = <A extends Assertion>(
log: ReadonlyArray<Transaction<A>>,
) => {
const live = new Map<string, A>();
for (const tx of log) {
for (const change of tx.changes) {
if (change.op === "assert") {
live.set(change.fact.id, change.fact);
} else {
live.delete(change.id);
}
}
}
return [...live.values()];
};
export const matches = (fact: Fact, pattern: Pattern) =>
Object.entries(pattern).every(([key, value]) => {
return fact[key as keyof Fact] === value;
});Replay walks transactions in order. Its temporary Map is keyed by assertion ID, rather than by entity and attribute. Deleting an entry from that Map doesn't delete the earlier journal entry. We can throw the projection away and compute it again.
Pattern matching retains Part 1's exact comparisons and wildcard omissions. We're changing how facts survive a correction, not inventing a query language yet.
Check the whole batch before publishing it
A retraction of an unknown assertion is probably a mistake. Reusing an assertion ID would make the evidence ambiguous. Check both before appending:
export const stage = Effect.fnUntraced(function* <
A extends Assertion,
>(
log: ReadonlyArray<Transaction<A>>,
input: ReadonlyArray<Change<A>>,
) {
const changes = structuredClone(input);
const live = new Set(replay(log).map((fact) => fact.id));
const used = new Set(
log.flatMap((tx) =>
tx.changes.flatMap((change) =>
change.op === "assert" ? [change.fact.id] : [],
),
),
);
for (const change of changes) {
if (change.op === "assert") {
const id = change.fact.id;
if (used.has(id)) {
return yield* new StoreError({
message: `assertion ID already used: ${id}`,
});
}
used.add(id);
live.add(id);
} else {
if (!live.delete(change.id)) {
return yield* new StoreError({
message: `assertion is not live: ${change.id}`,
});
}
}
}
return { position: log.length + 1, changes };
});used includes assertions that were later retracted. An ID cannot become available again just because its statement is no longer live. The checks also see earlier operations in the same batch: asserting and then retracting a new ID is allowed; asserting it twice is not. Reuse fails with assertion ID already used: training:1 in the tested example.
Staging changes local sets, not the stored log. Only a successful stage reaches the append:
export const makeStore = () => {
let log: ReadonlyArray<Transaction> = [];
const lock = Semaphore.makeUnsafe(1);
const transact = (changes: ReadonlyArray<Change>) =>
lock.withPermit(
Effect.gen(function* () {
const tx = yield* stage(log, changes);
log = [...log, tx];
return structuredClone(tx);
}),
);
const match = (pattern: Pattern) =>
Effect.sync(() =>
structuredClone(
replay(log).filter((fact) => matches(fact, pattern)),
),
);
const journal = Effect.sync(() => structuredClone(log));
return { transact, match, journal };
};One semaphore permit protects the check and append together. Two callers cannot both check the same old state and successfully retract the same assertion. Input and output copies keep callers from mutating the journal through references they hold.
This is atomicity inside one in-memory store. It is not crash safety or a lock shared by multiple processes.
Failure must leave no half-correction
Try a batch whose first retraction is valid and whose second isn't:
export const rollback = Effect.gen(function* () {
const store = makeStore();
yield* store.transact([{ op: "assert", fact: original }]);
const changes: ReadonlyArray<Change> = [
{ op: "retract", id: original.id },
{ op: "retract", id: "missing" },
];
const error = yield* store.transact(changes).pipe(Effect.flip);
return {
message: error.message,
current: yield* store.match({}),
positions: (yield* store.journal).map((tx) => tx.position),
};
});
// message: "assertion is not live: missing"
// current still has training:1, value true.
// positions: [1]The error is assertion is not live: missing. Maria's original assertion is still current. The journal still has only position 1. A rejected batch doesn't consume a position or leave the first retraction behind.
There is another difference from Part 1's single-value Map:
export const ambiguity = Effect.gen(function* () {
const store = makeStore();
yield* store.transact([{ op: "assert", fact: original }]);
yield* store.transact([
{
op: "assert",
fact: {
...original,
id: "training:2",
value: false,
},
},
]);
return (yield* store.match({})).map((fact) => fact.value);
});
// [true, false]: asserting a replacement is not a retraction.Without a retraction, both true and false are current. We deliberately allow multiple assertions for the same entity and attribute. Whether an attribute must have exactly one value is a rule, not a property of triples. Part 5 will give that rule somewhere to run.
What this version leaves out
We scan and replay the whole journal. There is no persistence, indexing, external input decoding, actor, command identity, or rule enforcement. Assertion IDs come from the caller. Transactions have an order, but no timestamps and no public as-of query yet.
This is what Triplex does
Real Triplex also distinguishes assertions from retractions and commits transaction changes atomically. Its assertion representation and service API are not these teaching types. See Core concepts for its correction model and Getting started for library calls. The backend guarantees remain those in Current state, not those of this closure over an array.
Next: when was it true, and when did we know?
The journal can now prove that Maria's record changed. It still cannot say whether training ended before her March 2 assignment or whether we learned about that ending later. A transaction position answers neither question. Part 3 gives the history two clocks.
The source and tests check the correction, rollback, ID reuse, competing retractions, and copy isolation. Back to the series.