Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ path does not restore it after a storage error. Treat this as a known durability
gap: a proper two-phase refactor must retain or restore the draft until the
fenced commit succeeds.

A decision's definition is frozen except for appended options. Any writer may
send `question:option` to add one while the question is open (at most 10 options,
no case-insensitive duplicate label). The server changes the sidecar record, the
open entry, and the plan `Option` projection together under the plan queue,
commits, and only then acknowledges and broadcasts `question:option-added`. The
client's `key` makes a retry return the same option. Shared drafts need no
rewrite: an option has a register only once someone selects it. New free-text
custom answers are not offered; an existing `custom` draft or answer still
renders and derives.

Anchors combine Yjs relative positions with canonical block digests. A position
survives surrounding edits; a digest can recover one unique block after a move
or epoch replacement. Ambiguous matches must orphan rather than guess. The safe
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ async function receive(ws: Socket, raw: string): Promise<void> {
if (room.plan) await Questions.cancel(room.plan, server, room.id, ws, frame);
return;

case "question:option":
if (room.plan) await Questions.addOption(room.plan, server, room.id, ws, frame);
return;

case "comment:start":
if (room.plan) await Comments.start(room.plan, server, room.id, ws, frame);
return;
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/plan/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,40 @@ export function projectAnswer(
});
}

/** Append one shared option to a question's projection in the plan. */
export function appendQuestionOption(
target: Document,
id: string,
question: string,
option: { id: string; label: string; description: string },
): Mutation | undefined {
return mutate(target, () => {
let found = false;
for (let node of $nodesOfType(QuestionnaireNode)) {
if (node.getId() !== id) continue;
let value = node.getQuestionnaire();
if (!value.questions.some(item => item.id === question)) continue;
found = true;
node.setQuestionnaire({
...value,
questions: value.questions.map(item =>
item.id === question
? {
...item,
options: [...item.options, {
id: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
}],
}
: item
),
});
}
return found;
});
}

/** Take a questionnaire out of the plan, leaving its record as history. */
export function removeQuestionnaire(target: Document, id: string): Mutation | undefined {
return mutate(target, () => {
Expand Down
244 changes: 244 additions & 0 deletions apps/server/src/questions.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,247 @@ test("an active implementation refuses to create a questionnaire", async () => {
expect(plan.records.size).toBe(0);
expect(room.project(plan.document)).not.toContain("<Questionnaire");
});

function member(handle = "ana") {
let sent: Array<Record<string, unknown>> = [];
let ws = {
data: { handle, client: `client-${handle}`, room: "test" },
send(raw: string) {
sent.push(JSON.parse(raw));
},
} as unknown as Socket;
return { ws, sent };
}

function adding(
plan: Plan,
server: Server<SocketData>,
ws: Socket,
id: string,
label: string,
key = "key-0000-0001",
) {
let question = plan.records.get(id)!.definition.questions[0].id;
return Questions.addOption(plan, server, "test", ws, {
kind: "question:option",
ts: 0,
rid: `rid-${key}-${label}`,
id,
question,
key,
label,
});
}

test("an appended option is durable in the record, draft store and plan before anyone hears of it", async () => {
let plan = await opened();
let published: Array<{ kind: string }> = [];
let server = {
publish(_topic: string, raw: string) {
published.push(JSON.parse(raw));
},
} as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
published.length = 0;
let { ws, sent } = member();

await adding(plan, server, ws, id, " A third way ");

let reply = sent.at(-1) as { ok: boolean; option: { id: string; label: string } };
expect(reply.ok).toBe(true);
expect(reply.option.label).toBe("A third way");
let item = plan.records.get(id)!.definition.questions[0];
let options = item.options;
expect(options.map(option => option.label)).toEqual(["Choose this", "A third way"]);
expect(Store.get(plan.questions, id)!.definition.questions[0].options).toEqual(options);
expect(room.project(plan.document)).toContain("A third way");
// Committed, not merely applied in memory.
expect(plan.persistence.lastSidecar).toContain("A third way");
expect(published.map(frame => frame.kind)).toEqual(["plan:update", "question:option-added"]);

// Everyone can choose it and the decision reads as the new label.
let snapshot = Store.snapshot(plan.questions, id);
if (!snapshot.open) throw new Error("not open");
let model = Question.crdt.Model.fromBinary(new Uint8Array(snapshot.model))
.fork() as unknown as Question.Model;
model.api.val([item.id, "choice"]).set(reply.option.id);
let edited = Store.edit(plan.questions, id, [...model.api.flush().toBinary()]);
if (!edited.open || !edited.accepted) throw new Error("could not choose the option");
let claimed = Store.claimSubmit(plan.questions, id, edited.revision, "ana");
if (!claimed.ok) throw new Error("could not claim");
expect(claimed.answers).toEqual([{ question: item.question, choices: ["A third way"] }]);
Store.commit(plan.questions, claimed.claim);
await asked.waiting;
});

test("repeating an option request with the same key returns the same option", async () => {
let plan = await opened();
let server = { publish() {} } as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
let { ws, sent } = member();

await adding(plan, server, ws, id, "Once");
let revision = plan.revision;
await adding(plan, server, ws, id, "Once");

let [first, second] = sent as Array<{ ok: boolean; option: { id: string }; repeated?: boolean }>;
expect(second!.option.id).toBe(first!.option.id);
expect(second!.repeated).toBe(true);
expect(plan.records.get(id)!.definition.questions[0].options).toHaveLength(2);
expect(plan.revision).toBe(revision);
});

test("an option is refused for duplicates, bounds, settled questions and active implementations", async () => {
let plan = await opened();
let server = { publish() {} } as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
let { ws, sent } = member();
let last = () => sent.at(-1) as { ok: boolean; reason?: string };

await adding(plan, server, ws, id, "choose THIS", "key-0000-0002");
expect(last()).toMatchObject({ ok: false, reason: "duplicate" });
await adding(plan, server, ws, id, " ", "key-0000-0003");
expect(last()).toMatchObject({ ok: false, reason: "invalid" });
await adding(plan, server, ws, id, "ok", "no");
expect(last()).toMatchObject({ ok: false, reason: "invalid" });
expect(plan.records.get(id)!.definition.questions[0].options).toHaveLength(1);

plan.execution = { id: "run-1" } as never;
await adding(plan, server, ws, id, "Blocked", "key-0000-0004");
expect(last()).toMatchObject({ ok: false, reason: "implementation" });
plan.execution = undefined;

for (let index = 0; index < Question.limits.MAX_SHARED_OPTIONS - 1; index++) {
await adding(plan, server, ws, id, `Extra ${index}`, `key-fill-${index}0000`);
expect(last().ok).toBe(true);
}
await adding(plan, server, ws, id, "Overflow", "key-0000-0005");
expect(last()).toMatchObject({ ok: false, reason: "full" });

let claimed = Store.claimCancel(plan.questions, id, "ana");
if (!claimed.ok) throw new Error("could not claim");
await adding(plan, server, ws, id, "During", "key-0000-0006");
expect(last()).toMatchObject({ ok: false, reason: "resolving" });
Store.commit(plan.questions, claimed.claim);
await adding(plan, server, ws, id, "After", "key-0000-0007");
expect(last()).toMatchObject({ ok: false, reason: "resolved" });
await asked.waiting;
});

test("a redefined open question survives dump and restore with its older draft", async () => {
let plan = await opened();
let server = { publish() {} } as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
let { ws } = member();
await adding(plan, server, ws, id, "Restored");

let restored = Store.restore(JSON.parse(JSON.stringify(Store.dump(plan.questions))));
let entry = Store.get(restored, id)!;
expect(entry.definition.questions[0].options.map(option => option.label)).toEqual([
"Choose this",
"Restored",
]);
expect(Question.read(entry.model, entry.definition)).toBeDefined();
Store.shutdown(restored);
let claimed = Store.claimCancel(plan.questions, id, "ana");
if (claimed.ok) Store.commit(plan.questions, claimed.claim);
await asked.waiting;
});

test("concurrent appends with one key add one option; with one label add one option", async () => {
let plan = await opened();
let server = { publish() {} } as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
let ana = member("ana");
let bo = member("bo");

await Promise.all([
adding(plan, server, ana.ws, id, "Same key", "key-same-0001"),
adding(plan, server, bo.ws, id, "Same key", "key-same-0001"),
adding(plan, server, ana.ws, id, "Same label", "key-label-0001"),
adding(plan, server, bo.ws, id, "same LABEL", "key-label-0002"),
]);

let labels = plan.records.get(id)!.definition.questions[0].options.map(option => option.label);
expect(labels).toEqual(["Choose this", "Same key", "Same label"]);
expect(Object.keys(plan.records.get(id)!.appended!)).toHaveLength(2);
expect((bo.sent.at(-1) as { reason?: string }).reason).toBe("duplicate");
let claimed = Store.claimCancel(plan.questions, id, "ana");
if (claimed.ok) Store.commit(plan.questions, claimed.claim);
await asked.waiting;
});

test("an implementation claimed while an append waits leaves the room untouched", async () => {
let plan = await opened();
let server = { publish() {} } as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
let { ws, sent } = member();
let before = room.project(plan.document);

let release = Promise.withResolvers<void>();
let held = Service.exclusive(plan, () => release.promise);
let pending = adding(plan, server, ws, id, "Late", "key-late-0001");
plan.claiming = true;
release.resolve();
await held;
await pending;
plan.claiming = false;

expect(sent.at(-1)).toMatchObject({ ok: false, reason: "implementation" });
expect(room.project(plan.document)).toBe(before);
expect(plan.records.get(id)!.definition.questions[0].options).toHaveLength(1);
expect(plan.records.get(id)!.appended).toBeUndefined();
let claimed = Store.claimCancel(plan.questions, id, "ana");
if (claimed.ok) Store.commit(plan.questions, claimed.claim);
await asked.waiting;
});

test("a failed commit restores the record and the open definition, and nobody is told", async () => {
let plan = await opened();
let published: Array<{ kind: string }> = [];
let server = {
publish(_topic: string, raw: string) {
published.push(JSON.parse(raw));
},
} as unknown as Server<SocketData>;
let asked = asking(plan, server, definition());
await asked.created;
let id = [...plan.records.keys()][0]!;
let { ws, sent } = member();
published.length = 0;

let original = plan.persistence.storage.collaboration.commit;
let fatal = plan.persistence.fatal;
plan.persistence.fatal = () => {};
plan.persistence.storage.collaboration.commit = () => Promise.reject(new Error("disk full"));
let quiet = console.error;
console.error = () => {};
try {
await adding(plan, server, ws, id, "Lost", "key-lost-0001");
} finally {
console.error = quiet;
plan.persistence.storage.collaboration.commit = original;
plan.persistence.fatal = fatal;
}

expect(sent.at(-1)).toMatchObject({ kind: "session:error" });
expect(published).toEqual([]);
expect(plan.records.get(id)!.definition.questions[0].options).toHaveLength(1);
expect(plan.records.get(id)!.appended).toBeUndefined();
expect(Store.get(plan.questions, id)!.definition.questions[0].options).toHaveLength(1);
let claimed = Store.claimCancel(plan.questions, id, "ana");
if (claimed.ok) Store.commit(plan.questions, claimed.claim);
await asked.waiting;
});
Loading
Loading