mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 22:06:10 +00:00
- A create member a recording sent empty ("", null, false, [] or {}) is no
longer required; a call that leaves it out is applied as sending that value
(body.blank).
- List views render envelope members that list the listed entities' ids
(Paperless's `all`), so creates in such collections are confirmed.
- Updates move their entity in collections kept sorted by a field they write,
and a failed whole-collection read after an update can teach that order.
- A later create that stores an unruled field otherwise is withheld at the
create, and an item read may fill values a create's answer had not loaded.
- An unsent field respelled between null, [] and {} is unchanged.
- The skill asks for one create with every field and one with only what a
person must give.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
767 lines
24 KiB
TypeScript
767 lines
24 KiB
TypeScript
import {
|
|
createdFields,
|
|
documentBodyProblem,
|
|
optionalFields,
|
|
renewUuids,
|
|
updatedEntity,
|
|
updatedFields,
|
|
valueAt,
|
|
writtenDocument,
|
|
writtenResponse,
|
|
type GenerateValue,
|
|
} from "../../domain/analysisView/environmentStateWriteApply.js";
|
|
import {
|
|
insertIndex,
|
|
nextInteger,
|
|
} from "../../domain/analysisView/environmentStateWriteExplain.js";
|
|
import { callRenewal } from "../../domain/analysisView/environmentStateWriteGenerated.js";
|
|
import {
|
|
nestedClockValue,
|
|
unenveloped,
|
|
} from "../../domain/analysisView/environmentStateWriteStamps.js";
|
|
import {
|
|
linkedCollection,
|
|
sameLinkedSource,
|
|
} from "../../domain/analysisView/environmentStateWriteLinks.js";
|
|
import {
|
|
pathValuesOf,
|
|
type GeneratedRule,
|
|
type StateWritePlan,
|
|
type WriteCall,
|
|
} from "../../domain/analysisView/environmentStateWriteRules.js";
|
|
import { withRead } from "../../domain/analysisView/environmentStateWriteObserve.js";
|
|
import {
|
|
formatClock,
|
|
generatedIntegerDigits,
|
|
seededInteger,
|
|
seededLike,
|
|
spelledLike,
|
|
} from "../../domain/analysisView/environmentStateWriteValues.js";
|
|
import {
|
|
ownMember,
|
|
collectionMatch,
|
|
decodedSegment,
|
|
isObject,
|
|
same,
|
|
scalarText,
|
|
type JsonObject,
|
|
type StateEntity,
|
|
} from "../../domain/analysisView/environmentStateRules.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import {
|
|
replayAnswer,
|
|
selectReplayExchange,
|
|
type ObservedQuery,
|
|
type ReplayArguments,
|
|
} from "./environmentReplay.js";
|
|
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
|
import { callSegments } from "./environmentStateCallLines.js";
|
|
import {
|
|
itemCall,
|
|
missingAnswer,
|
|
namedEntity,
|
|
recordedJson,
|
|
refused,
|
|
stateAnswer,
|
|
type StateAnswer,
|
|
type StateProvenance,
|
|
type StateServing,
|
|
} from "./environmentStateAnswer.js";
|
|
import { createdCollection } from "./environmentStateCollections.js";
|
|
import {
|
|
isLoadableValue,
|
|
unloadableMember,
|
|
type HeldDocument,
|
|
} from "./environmentStateDocument.js";
|
|
import { environmentToolArguments } from "./environmentTools.js";
|
|
|
|
/** Attempts at a generated UUID or token before a collision with held values is refused. */
|
|
const GENERATION_ATTEMPTS = 64;
|
|
|
|
/** One served write call: the tool, its plan and its arguments. */
|
|
export interface WriteRequest {
|
|
readonly tool: EnvironmentManifestTool;
|
|
readonly plan: StateWritePlan;
|
|
readonly args: ReplayArguments;
|
|
readonly observedQuery?: ObservedQuery;
|
|
}
|
|
|
|
/** The path values a call supplies, keyed by template segment as write plans name them. */
|
|
export const pathValues = (
|
|
tool: EnvironmentManifestTool,
|
|
args: ReplayArguments,
|
|
): Map<string, string> => {
|
|
const names = environmentToolArguments(tool).path;
|
|
const template = tool.path_template
|
|
.slice(tool.path_template.indexOf("/") + 1)
|
|
.split("/");
|
|
const values = new Map<string, string>();
|
|
for (const param of tool.params) {
|
|
const value = ownMember(args, names.get(param.name) ?? param.name);
|
|
const segment = template[param.index] ?? "";
|
|
if (typeof value === "string") values.set(segment.slice(1, -1), value);
|
|
}
|
|
return values;
|
|
};
|
|
|
|
/** A refusal for a value `data/add` would refuse, so `data/get` always reports a state that loads back. */
|
|
export const unloadable = (
|
|
tool: EnvironmentManifestTool,
|
|
holder: string,
|
|
): AnalysisError =>
|
|
refused(
|
|
tool,
|
|
`${holder} would hold a value that is not exactly representable JSON, which data/add could not load back.`,
|
|
);
|
|
|
|
/** The call's body: an object, unwrapped from the envelope the capture sent it in, nothing, or a refusal for any other value or one holding a value the state cannot hold. */
|
|
const callBody = ({
|
|
tool,
|
|
args,
|
|
plan,
|
|
}: WriteRequest): Result<JsonObject | undefined, AnalysisError> => {
|
|
const name = environmentToolArguments(tool).body;
|
|
const sent = name === undefined ? undefined : ownMember(args, name);
|
|
if (sent === undefined) return ok(undefined);
|
|
const envelope = plan.body?.envelope ?? "";
|
|
const value = unenveloped(sent as JsonValue, plan.body?.envelope);
|
|
if (value === undefined)
|
|
return err(
|
|
refused(
|
|
tool,
|
|
`Tool ${tool.name} takes its JSON object body wrapped as {"${envelope}":[{...}]}, as the capture sent it.`,
|
|
),
|
|
);
|
|
if (!isObject(value))
|
|
return err(refused(tool, `Tool ${tool.name} takes a JSON object body.`));
|
|
const member = unloadableMember(value as JsonObject);
|
|
return member === undefined
|
|
? ok(value as JsonObject)
|
|
: err(unloadable(tool, `Member ${member}`));
|
|
};
|
|
|
|
/** The object a recorded response shows at a pointer. */
|
|
const recordedObject = (
|
|
serving: StateServing,
|
|
at: { readonly ordinal: number; readonly pointer: string },
|
|
): JsonObject | undefined => {
|
|
const exchange = serving.source.exchanges.get(at.ordinal);
|
|
const body = exchange === undefined ? undefined : recordedJson(exchange);
|
|
const object = body === undefined ? undefined : valueAt(body, at.pointer);
|
|
return isObject(object) ? object : undefined;
|
|
};
|
|
|
|
/** The entity a recorded observation shows, with a create's read-back over it: the values a create keeps and an update's constants. */
|
|
export const observedEntity = (
|
|
serving: StateServing,
|
|
plan: StateWritePlan,
|
|
): JsonObject | undefined => {
|
|
if (plan.observation === null) return undefined;
|
|
const object = recordedObject(serving, plan.observation);
|
|
if (object === undefined || plan.observation.read === undefined)
|
|
return object;
|
|
const read = recordedObject(serving, plan.observation.read);
|
|
return read === undefined
|
|
? undefined
|
|
: withRead(object, read, plan.observation.read.shape);
|
|
};
|
|
|
|
/** One past the largest integer these entities hold in the field, read as the exemplar spells it, or undefined when none holds one. */
|
|
const nextHeld = (
|
|
entities: readonly StateEntity[],
|
|
field: string,
|
|
exemplar: JsonValue,
|
|
) =>
|
|
nextInteger(
|
|
entities.map((entity) => entity.fields),
|
|
field,
|
|
exemplar,
|
|
);
|
|
|
|
/** The entity a write generates values for: its collection, the observed entity and, for an update, the entity it writes. */
|
|
export interface GeneratedTarget {
|
|
readonly name: string;
|
|
readonly collection: readonly StateEntity[];
|
|
readonly observed: JsonObject;
|
|
/** The entity an update writes, whose values a renewed field keeps. */
|
|
readonly current?: JsonObject;
|
|
}
|
|
|
|
/** The fields of each entity of a type with an id the live state holds, which a referencing write repeats. */
|
|
export const heldEntity =
|
|
(serving: StateServing) =>
|
|
(type: string, id: string): JsonObject[] =>
|
|
serving.store
|
|
.ofType(type)
|
|
.flatMap((entity) => (entity.id === id ? [entity.fields] : []));
|
|
|
|
/** What an update generates values for: the entity it writes, in its collection. */
|
|
export const updateTarget = (
|
|
serving: StateServing,
|
|
entity: StateEntity,
|
|
observed: JsonObject,
|
|
): GeneratedTarget => ({
|
|
name: entity.collection,
|
|
collection: serving.store.inCollection(entity.type, entity.collection),
|
|
observed,
|
|
current: entity.fields,
|
|
});
|
|
|
|
/**
|
|
* Values one served write generates: its clock instant, sequences one past the largest held
|
|
* value, and UUIDs and tokens seeded by the state Evidence, tool, field and write count,
|
|
* skipping values the type already holds. Every entity the call writes shares them.
|
|
*/
|
|
export const generator = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
): {
|
|
readonly forTarget: (target: GeneratedTarget) => GenerateValue;
|
|
readonly taken: Map<string, number>;
|
|
} => {
|
|
const { store } = serving;
|
|
const write = store.pendingWrite();
|
|
const taken = new Map<string, number>();
|
|
const sequence = (key: string, held: number | undefined): number => {
|
|
const value = store.sequenceAfter(key, held);
|
|
taken.set(key, value);
|
|
return value;
|
|
};
|
|
const ofType = store.ofType(request.plan.target ?? "");
|
|
// A sequence never reuses a value the state started with, even after its entity was deleted.
|
|
const started = store.startedWith(request.plan.target ?? "");
|
|
const startedIn = (collection: string) =>
|
|
started.filter((held) => held.collection === collection);
|
|
const renewed = callRenewal(
|
|
`${store.state.evidenceId}:${request.tool.name}:renewed:${String(write.count)}`,
|
|
);
|
|
const unused = (
|
|
field: string,
|
|
make: (attempt: number) => JsonValue,
|
|
): Result<JsonValue, string> => {
|
|
const held = new Set(
|
|
ofType.map((entity) => scalarText(ownMember(entity.fields, field))),
|
|
);
|
|
for (let attempt = 0; attempt < GENERATION_ATTEMPTS; attempt += 1) {
|
|
const value = make(attempt);
|
|
if (!held.has(scalarText(value))) return ok(value);
|
|
}
|
|
return err(
|
|
`Could not generate a unique value for ${field} after ${String(GENERATION_ATTEMPTS)} attempts without colliding with the held state.`,
|
|
);
|
|
};
|
|
const generate = (
|
|
target: GeneratedTarget,
|
|
rule: GeneratedRule,
|
|
): Result<JsonValue, string> => {
|
|
const exemplar = ownMember(target.observed, rule.field) ?? "";
|
|
const seed = (attempt: number) =>
|
|
`${store.state.evidenceId}:${request.tool.name}:${rule.field}:${String(write.count)}:${String(attempt)}`;
|
|
switch (rule.rule) {
|
|
case "clock":
|
|
case "held-clock":
|
|
case "nested-clock": {
|
|
const value = formatClock(write.clockMs, rule.format);
|
|
if (value === undefined)
|
|
return err(
|
|
`The generated clock for ${rule.field} is outside its recorded four-digit year format.`,
|
|
);
|
|
return rule.rule === "nested-clock"
|
|
? nestedClockValue(
|
|
ownMember(target.current ?? target.observed, rule.field),
|
|
rule,
|
|
value,
|
|
)
|
|
: ok(value);
|
|
}
|
|
case "sequence":
|
|
return ok(
|
|
spelledLike(
|
|
exemplar,
|
|
sequence(
|
|
`${request.tool.name} ${rule.field} ${rule.scope === "collection" ? target.name : ""}`,
|
|
nextHeld(
|
|
rule.scope === "collection"
|
|
? [...target.collection, ...startedIn(target.name)]
|
|
: [...ofType, ...started],
|
|
rule.field,
|
|
exemplar,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
case "unique":
|
|
return unused(rule.field, (attempt) =>
|
|
generatedIntegerDigits(exemplar) === undefined
|
|
? seededLike(seed(attempt), exemplar)
|
|
: seededInteger(seed(attempt), exemplar),
|
|
);
|
|
case "renewed":
|
|
return ok(
|
|
renewUuids(
|
|
ownMember(target.current, rule.field) ?? null,
|
|
rule.pointers,
|
|
renewed,
|
|
),
|
|
);
|
|
case "identity":
|
|
if (rule.generator === "sequence")
|
|
return ok(
|
|
spelledLike(
|
|
exemplar,
|
|
sequence(
|
|
`${request.plan.target ?? ""} ${rule.field} ${target.name}`,
|
|
nextHeld(
|
|
[...target.collection, ...startedIn(target.name)],
|
|
rule.field,
|
|
exemplar,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return unused(rule.field, (attempt) =>
|
|
seededLike(seed(attempt), exemplar, rule.generator === "uuid"),
|
|
);
|
|
}
|
|
};
|
|
return { forTarget: (target) => (rule) => generate(target, rule), taken };
|
|
};
|
|
|
|
/** A write's answer on its recorded response's status and content type: a rendered body, none, or the recorded one. */
|
|
export const writeAnswer = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
rendered:
|
|
| { readonly fields: JsonObject | undefined; readonly id: string }
|
|
| ((
|
|
recorded: JsonValue | undefined,
|
|
) => Result<JsonValue | undefined, string>),
|
|
state: StateProvenance,
|
|
): Result<StateAnswer, AnalysisError> => {
|
|
const { plan, tool, args } = request;
|
|
const exchange =
|
|
plan.response === null
|
|
? undefined
|
|
: serving.source.exchanges.get(plan.response.ordinal);
|
|
if (plan.response === null || exchange === undefined)
|
|
return err(refused(tool, `Tool ${tool.name} has no recorded response.`));
|
|
const exact =
|
|
selectReplayExchange(
|
|
tool,
|
|
new Map([[exchange.ordinal, exchange]]),
|
|
args,
|
|
request.observedQuery,
|
|
)?.exact === true;
|
|
if (plan.response.shape === "recorded")
|
|
return ok({ ...replayAnswer(serving.source, exchange, exact), state });
|
|
const recorded = recordedJson(exchange);
|
|
const body =
|
|
typeof rendered === "function"
|
|
? rendered(recorded)
|
|
: writtenResponse(
|
|
plan.response,
|
|
recorded,
|
|
{
|
|
...rendered,
|
|
clocks:
|
|
plan.observation?.read?.shape === "item"
|
|
? plan.rules.flatMap((rule) =>
|
|
rule.rule === "clock" ? [rule.field] : [],
|
|
)
|
|
: [],
|
|
},
|
|
optionalFields(plan),
|
|
);
|
|
if (!body.ok) return err(refused(tool, body.error));
|
|
return stateAnswer(serving, { exchange, exact }, body.value ?? null, state);
|
|
};
|
|
|
|
/** The path arguments a call supplies as given, by the template segment each fills. */
|
|
const pathArguments = (
|
|
tool: EnvironmentManifestTool,
|
|
args: ReplayArguments,
|
|
): Map<number, string> => {
|
|
const names = environmentToolArguments(tool).path;
|
|
return new Map(
|
|
tool.params.flatMap((param) => {
|
|
const value = ownMember(args, names.get(param.name) ?? param.name);
|
|
return typeof value === "string" ? [[param.index, value] as const] : [];
|
|
}),
|
|
);
|
|
};
|
|
|
|
/** The path of the recorded call a plan was built from. */
|
|
const exemplarPath = (
|
|
serving: StateServing,
|
|
plan: StateWritePlan,
|
|
): string | undefined =>
|
|
plan.exemplar_ordinal === null
|
|
? undefined
|
|
: serving.source.exchanges.get(plan.exemplar_ordinal)?.path;
|
|
|
|
/** The collection a create writes into: a linked plan's, only for its exemplar's path arguments, else the one the call's path selects. */
|
|
const writtenCollection = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
): Result<string | undefined, AnalysisError> => {
|
|
const { tool, plan } = request;
|
|
const linked = linkedCollection(plan, tool.path_template);
|
|
if (linked === undefined)
|
|
return ok(
|
|
createdCollection(serving, tool, request.args, plan.target ?? ""),
|
|
);
|
|
// No read showed where other path arguments create.
|
|
return sameLinkedSource(
|
|
tool.path_template,
|
|
exemplarPath(serving, plan),
|
|
pathArguments(tool, request.args),
|
|
)
|
|
? ok(linked)
|
|
: err(
|
|
refused(
|
|
tool,
|
|
`Tool ${tool.name} creates into ${linked} only with the path arguments its recording sent.`,
|
|
),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* The path values a create's path fields take, decoded once as the gate learned them: a linked
|
|
* create accepts its exemplar's source only, so it takes that; another the collection this call
|
|
* selects, else its own arguments.
|
|
*/
|
|
const createdPathValues = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
call: WriteCall,
|
|
collection: string,
|
|
): ReadonlyMap<string, string> => {
|
|
const { tool, plan } = request;
|
|
const exemplar =
|
|
linkedCollection(plan, tool.path_template) === undefined
|
|
? undefined
|
|
: exemplarPath(serving, plan);
|
|
return (
|
|
(exemplar === undefined
|
|
? undefined
|
|
: pathValuesOf(tool.path_template, exemplar)) ??
|
|
pathValuesOf(tool.path_template, collection) ??
|
|
new Map(
|
|
[...call.pathValues].map(([name, value]) => [
|
|
name,
|
|
decodedSegment(value),
|
|
]),
|
|
)
|
|
);
|
|
};
|
|
|
|
const answerCreate = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
call: WriteCall,
|
|
): Result<StateAnswer, AnalysisError> => {
|
|
const { tool, plan } = request;
|
|
const type = plan.target ?? "";
|
|
const idField = serving.store.type(type)?.id_field ?? "";
|
|
const target = writtenCollection(serving, request);
|
|
if (!target.ok) return target;
|
|
const collection = target.value;
|
|
const observed = observedEntity(serving, plan);
|
|
if (collection === undefined || observed === undefined)
|
|
return err(
|
|
refused(
|
|
tool,
|
|
`Tool ${tool.name} needs every path argument to be non-empty.`,
|
|
),
|
|
);
|
|
if (plan.collection !== null && plan.collection !== collection)
|
|
return err(
|
|
refused(
|
|
tool,
|
|
`The recorded create's values are tied to ${plan.collection}; the environment models creates only there.`,
|
|
),
|
|
);
|
|
const members = serving.store.inCollection(type, collection);
|
|
const values = generator(serving, request);
|
|
const created = {
|
|
...call,
|
|
pathValues: createdPathValues(serving, request, call, collection),
|
|
};
|
|
const fields = createdFields(
|
|
plan,
|
|
observed,
|
|
created,
|
|
values.forTarget({ name: collection, collection: members, observed }),
|
|
heldEntity(serving),
|
|
);
|
|
if (!fields.ok) return err(refused(tool, fields.error));
|
|
const field = unloadableMember(fields.value);
|
|
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
|
const id = scalarText(ownMember(fields.value, idField));
|
|
if (
|
|
id === undefined ||
|
|
serving.store.entity(type, collection, id) !== undefined
|
|
)
|
|
return err(
|
|
refused(
|
|
tool,
|
|
`The state already holds a ${type} entity ${id ?? ""} in ${collection}.`,
|
|
),
|
|
);
|
|
const state = {
|
|
source: "state",
|
|
view: "create",
|
|
type,
|
|
collection,
|
|
id,
|
|
} as const;
|
|
const answer = writeAnswer(
|
|
serving,
|
|
request,
|
|
{ fields: fields.value, id },
|
|
state,
|
|
);
|
|
if (!answer.ok) return answer;
|
|
const inserted = serving.store.insert(
|
|
{
|
|
type,
|
|
collection,
|
|
id,
|
|
fields: fields.value,
|
|
view_fields: [],
|
|
record_ordinals: [],
|
|
},
|
|
insertIndex(
|
|
plan.insert ?? { rule: "last" },
|
|
members.map((entity) => entity.fields),
|
|
fields.value,
|
|
),
|
|
);
|
|
if (!inserted.ok) return inserted;
|
|
serving.store.commitWrite(values.taken);
|
|
return answer;
|
|
};
|
|
|
|
/** The held entity an update or delete names, or the answer for one the state does not hold. */
|
|
const targetEntity = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
): Result<StateEntity, Result<StateAnswer, AnalysisError>> => {
|
|
const { tool, plan, args } = request;
|
|
const call = itemCall(tool, args, plan.address?.by === "action");
|
|
if (call === undefined)
|
|
return err(
|
|
err(refused(tool, `Tool ${tool.name} needs every path argument.`)),
|
|
);
|
|
const entity = namedEntity(serving.store, plan.target ?? "", call);
|
|
return entity === undefined
|
|
? err(
|
|
missingAnswer(serving, tool, {
|
|
type: plan.target ?? "",
|
|
collection: `/${call.segments.map(({ text }) => text).join("/")}`,
|
|
id: call.id,
|
|
}),
|
|
)
|
|
: ok(entity);
|
|
};
|
|
|
|
const answerUpdate = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
call: WriteCall,
|
|
): Result<StateAnswer, AnalysisError> => {
|
|
const { tool, plan } = request;
|
|
const target = targetEntity(serving, request);
|
|
if (!target.ok) return target.error;
|
|
const entity = target.value;
|
|
const observed = observedEntity(serving, plan) ?? {};
|
|
const values = generator(serving, request);
|
|
const written = updatedFields(
|
|
plan,
|
|
{ before: entity.fields, observed },
|
|
{ ...call, id: entity.id },
|
|
values.forTarget(updateTarget(serving, entity, observed)),
|
|
heldEntity(serving),
|
|
);
|
|
if (!written.ok) return err(refused(tool, written.error));
|
|
// An increment can pass the exact integers.
|
|
const field = unloadableMember(written.value);
|
|
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
|
const answer = writeAnswer(
|
|
serving,
|
|
request,
|
|
{ fields: written.value, id: entity.id },
|
|
{
|
|
source: "state",
|
|
view: "update",
|
|
type: entity.type,
|
|
collection: entity.collection,
|
|
id: entity.id,
|
|
},
|
|
);
|
|
if (!answer.ok) return answer;
|
|
const updated = serving.store.update(
|
|
updatedEntity(entity, written.value),
|
|
resortedIndex(serving, plan, { entity, fields: written.value }),
|
|
);
|
|
if (!updated.ok) return updated;
|
|
serving.store.commitWrite(values.taken);
|
|
return answer;
|
|
};
|
|
|
|
/** Where an update moves its entity among the others of a collection sorted by a field it changed; undefined to keep it in place. */
|
|
export const resortedIndex = (
|
|
serving: StateServing,
|
|
plan: StateWritePlan,
|
|
written: { readonly entity: StateEntity; readonly fields: JsonObject },
|
|
): number | undefined => {
|
|
const { insert } = plan;
|
|
const { entity, fields } = written;
|
|
if (
|
|
insert?.rule !== "sorted" ||
|
|
same(
|
|
ownMember(entity.fields, insert.field),
|
|
ownMember(fields, insert.field),
|
|
)
|
|
)
|
|
return undefined;
|
|
return insertIndex(
|
|
insert,
|
|
serving.store
|
|
.inCollection(entity.type, entity.collection)
|
|
.filter((member) => member.id !== entity.id)
|
|
.map((member) => member.fields),
|
|
fields,
|
|
);
|
|
};
|
|
|
|
const answerDelete = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
): Result<StateAnswer, AnalysisError> => {
|
|
const target = targetEntity(serving, request);
|
|
if (!target.ok) return target.error;
|
|
const entity = target.value;
|
|
const answer = writeAnswer(
|
|
serving,
|
|
request,
|
|
{ fields: entity.fields, id: entity.id },
|
|
{
|
|
source: "state",
|
|
view: "delete",
|
|
type: entity.type,
|
|
collection: entity.collection,
|
|
id: entity.id,
|
|
},
|
|
);
|
|
if (answer.ok) {
|
|
serving.store.remove(entity);
|
|
serving.store.commitWrite(new Map());
|
|
}
|
|
return answer;
|
|
};
|
|
|
|
/** The held document a call changes: only at the path the state holds it, with members the capture sent as the JSON types the document holds. */
|
|
const documentTarget = (
|
|
serving: StateServing,
|
|
{ tool, plan, args }: WriteRequest,
|
|
call: WriteCall,
|
|
): Result<HeldDocument, AnalysisError> => {
|
|
const held = serving.store.document(plan.target ?? "");
|
|
const segments = callSegments(tool, args);
|
|
if (held === undefined || plan.body === null)
|
|
return err(
|
|
refused(tool, `The state holds no document for ${plan.target ?? ""}.`),
|
|
);
|
|
if (segments === undefined)
|
|
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
|
// Document reads answer only on their recorded line, so a write elsewhere has no document to change.
|
|
if (collectionMatch(held.path, segments) === undefined)
|
|
return err(
|
|
refused(
|
|
tool,
|
|
`The state holds this document only at ${held.path}; the environment models writes to it only there.`,
|
|
),
|
|
);
|
|
const initial = serving.store.state.documents.find(
|
|
(document) => document.tool === held.tool,
|
|
);
|
|
const problem = documentBodyProblem(plan, call, {
|
|
initial: initial?.value,
|
|
current: held.value,
|
|
});
|
|
return problem === undefined ? ok(held) : err(refused(tool, problem));
|
|
};
|
|
|
|
const answerDocument = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
call: WriteCall,
|
|
): Result<StateAnswer, AnalysisError> => {
|
|
const { tool, plan } = request;
|
|
const target = documentTarget(serving, request, call);
|
|
if (!target.ok) return target;
|
|
const held = target.value;
|
|
const written = writtenDocument(plan, held.value, call);
|
|
if (!written.ok) return err(refused(tool, written.error));
|
|
if (!isLoadableValue(written.value))
|
|
return err(unloadable(tool, "The document"));
|
|
const state = {
|
|
source: "state",
|
|
view: "document-write",
|
|
path: held.path,
|
|
} as const;
|
|
const answer =
|
|
plan.response?.shape === "entity"
|
|
? (() => {
|
|
const exchange = serving.source.exchanges.get(plan.response.ordinal);
|
|
return exchange === undefined
|
|
? err(refused(tool, `Tool ${tool.name} has no recorded response.`))
|
|
: stateAnswer(
|
|
serving,
|
|
{ exchange, exact: true },
|
|
written.value,
|
|
state,
|
|
);
|
|
})()
|
|
: writeAnswer(serving, request, { fields: undefined, id: "" }, state);
|
|
if (!answer.ok) return answer;
|
|
const set = serving.store.setDocument(held.tool, written.value);
|
|
if (!set.ok) return set;
|
|
serving.store.commitWrite(new Map());
|
|
return answer;
|
|
};
|
|
|
|
/** Apply one served write tool call to the live state and answer it; a refused call leaves the state unchanged. */
|
|
export const answerWrite = (
|
|
serving: StateServing,
|
|
request: WriteRequest,
|
|
): Result<StateAnswer, AnalysisError> => {
|
|
const body = callBody(request);
|
|
if (!body.ok) return body;
|
|
const call: WriteCall = {
|
|
path: request.tool.path_template,
|
|
pathValues: pathValues(request.tool, request.args),
|
|
body: body.value,
|
|
};
|
|
switch (request.plan.effect) {
|
|
case "create":
|
|
return answerCreate(serving, request, call);
|
|
case "update":
|
|
return answerUpdate(serving, request, call);
|
|
case "delete":
|
|
return answerDelete(serving, request);
|
|
case "document":
|
|
return answerDocument(serving, request, call);
|
|
case "none":
|
|
return err(
|
|
refused(
|
|
request.tool,
|
|
`Tool ${request.tool.name} is not served from the state.`,
|
|
),
|
|
);
|
|
}
|
|
};
|