Repository navigation
feat(denkraum): split ideas and question import onto the store (DR-04c-2) #674
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { DeskError, latestAnswer, projectQuestionPriority, compareQuestions, migrateState } from './store/model.mjs'; | ||
| export { LedgerStore as DeskStore } from './store/ledger.mjs'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| // Ideas and question import (DR-04c-2): the last P1 operations, completing the store surface. | ||
| // Split from the P1 DeskStore (putIdea/putQuestion); neither needs the root agent id. | ||
| import { randomUUID } from 'node:crypto'; | ||
| import { ProgressStore } from './progress.mjs'; | ||
| import { ensure, id, ideaDefaults, ideaPriorities, now, object, priorityRefCurrent, requireCurrentSource, sameRef, text, | ||
| validateQuestion, validIdeaCategory } from './model.mjs'; | ||
|
|
||
| export class LedgerStore extends ProgressStore { | ||
| async putIdea(input) { | ||
| object(input, 'Idee'); | ||
| const requestId = id(input.requestId); const ideaId = input.id === undefined ? null : id(input.id); | ||
| ensure(ideaId ? Number.isSafeInteger(input.expectedRevision) && input.expectedRevision > 0 : input.expectedRevision === null, 'Ungültige expectedRevision.'); | ||
| const content = { title: text(input.title, 'Titel', 200), text: text(input.text, 'Idee', 8000), | ||
| source: input.source === undefined || input.source === null ? null : text(input.source, 'Quelle', 2000) }; | ||
| if (Object.hasOwn(input, 'category')) { | ||
| ensure(typeof input.category === 'string' && validIdeaCategory(input.category.trim()), 'Ungültige Ideenkategorie.'); | ||
| content.category = input.category.trim(); | ||
| } | ||
| if (Object.hasOwn(input, 'userPriority')) { | ||
| ensure(ideaPriorities.includes(input.userPriority), 'Ungültige Ideenpriorität.'); | ||
| content.userPriority = input.userPriority; | ||
| } | ||
| const payload = JSON.stringify({ id: ideaId, expectedRevision: input.expectedRevision, ...content }); | ||
| return this.change(state => { | ||
| ensure(state.schemaVersion === 2, 'Explizite V2-Migration erforderlich.', 409); | ||
| // ponytail: request replay scans the local history; index request IDs when the ledger grows large. | ||
| for (const idea of state.ideas) { | ||
| const replay = idea.revisions.find(r => r.requestId === requestId); | ||
| if (replay) { ensure(replay.payload === payload, 'requestId bereits mit anderem Inhalt verwendet.', 409); return { id: idea.id, ...replay }; } | ||
| } | ||
| let idea = ideaId ? state.ideas.find(i => i.id === ideaId) : null; | ||
| if (ideaId) ensure(idea, 'Idee nicht gefunden.', 404); | ||
| if (idea) ensure(idea.revisions.at(-1).revision === input.expectedRevision, 'Ideenrevision verändert.', 409); | ||
| else { idea = { id: randomUUID(), revisions: [] }; state.ideas.push(idea); } | ||
| const metadata = { ...ideaDefaults(idea.revisions.at(-1) ?? {}), ...content }; | ||
| const revision = { revision: idea.revisions.length + 1, eventId: randomUUID(), requestId, payload, ...metadata, createdAt: now() }; | ||
| revision.webhookDelivery = { status: 'pending', attempts: 0, lastAttemptAt: null, queuedAt: null, lastError: null }; | ||
| idea.revisions.push(revision); | ||
| state.progress.push({ eventId: revision.eventId, currentStatus: 'incoming', progressRevision: 1, | ||
| history: [{ progressRevision: 1, from: null, to: 'incoming', actor: 'user', at: revision.createdAt, reason: 'Idee gespeichert.', evidenceRefs: [] }] }); | ||
| return { id: idea.id, ...revision }; | ||
| }); | ||
| } | ||
| async putQuestion(input) { | ||
| const q = validateQuestion(input); | ||
| const requestId = input.requestId === undefined ? null : id(input.requestId); | ||
| const payload = requestId ? JSON.stringify({ id: input.id ?? null, expectedRevision: input.expectedRevision ?? null, question: { ...q, id: input.id ?? null } }) : null; | ||
| return this.change(state => { | ||
| const replay = requestId && state.questionImports?.find(r => r.requestId === requestId); | ||
| if (replay) { ensure(replay.payload === payload, 'Import-requestId bereits anders verwendet.', 409); return replay.result; } | ||
| const index = state.questions.findIndex(x => x.id === q.id); const old = state.questions[index]; | ||
| requireCurrentSource(state, { ...old, ...q }); | ||
| const detail = q.priorityDetail ?? old?.priorityDetail; | ||
| if (detail?.sourceRefs.length) { | ||
| ensure(detail.sourceRefs.every(ref => priorityRefCurrent(state, ref)), 'Prioritätsquelle ist nicht aktuell.', 409); | ||
| const sourceRef = q.sourceRef ?? old?.sourceRef; | ||
| ensure(!sourceRef || detail.sourceRefs.some(ref => sameRef(ref, sourceRef)), 'Priorität ist nicht an diese Quelle gebunden.', 409); | ||
| } | ||
| ensure(old || input.expectedRevision === undefined, 'Frage fehlt für diese Revision. Erst neu laden.', 409); | ||
| ensure(!old || input.expectedRevision === old.revision, 'Frage wurde inzwischen geändert. Erst neu laden.', 409); | ||
| if (old?.recommendationDetail && q.sourceRef && !sameRef(old.sourceRef, q.sourceRef) && q.recommendationDetail === undefined) q.recommendationDetail = undefined; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an imported update changes Useful? React with 👍 / 👎. |
||
| const next = { ...old, ...q, revision: (old?.revision ?? 0) + 1, createdAt: old?.createdAt ?? now(), updatedAt: now() }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the system clock advances between these consecutive Useful? React with 👍 / 👎. |
||
| if (old) state.questions[index] = next; else state.questions.push(next); | ||
| if (requestId) { state.questionImports ??= []; state.questionImports.push({ requestId, payload, result: structuredClone(next) }); } | ||
| return next; | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { mkdtemp, readFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { test } from 'node:test'; | ||
| import { LedgerStore } from './ledger.mjs'; | ||
| import { DeskError } from './model.mjs'; | ||
| import * as surface from '../store.mjs'; | ||
|
|
||
| const ROOT = 'test-root-agent'; | ||
| const status = code => e => e instanceof DeskError && e.status === code; | ||
| const opt = id => ({ id, label: id, rationale: 'r', impact: 'i', tradeoff: 't', effort: 'e', reversible: 'ja' }); | ||
| const question = (extra = {}) => ({ id: 'E-1', title: 'T', context: 'C', owner: 'O', category: 'K', scope: 'S', source: 'Q', uncertainty: 'U', | ||
| recommendation: { optionIds: ['a'], rationale: 'R' }, options: [opt('a'), opt('b')], ...extra }); | ||
| const idea = (extra = {}) => ({ requestId: 'idea-1', expectedRevision: null, title: 'Idee', text: 'Beschreibung', ...extra }); | ||
| async function fresh({ v2 = true, ...config } = {}) { | ||
| const s = new LedgerStore(join(await mkdtemp(join(tmpdir(), 'denkraum-ledger-')), 'ledger.json'), { rootAgentId: ROOT, ...config }); | ||
| if (v2) { await s.putQuestion(question({ id: 'seed' })); await s.migrate((await s.read()).revision); } | ||
| return s; | ||
| } | ||
|
|
||
| test('an idea revision starts incoming progress and a pending outbox entry and keeps its metadata', async () => { | ||
| const calls = []; const s = await fresh({ notifyEvent: async (event, id) => calls.push({ event, id }) }); | ||
| const first = await s.putIdea(idea({ category: ' Werkzeug ', userPriority: 'high' })); | ||
| assert.equal(first.revision, 1); assert.equal(first.category, 'Werkzeug'); assert.equal(first.userPriority, 'high'); | ||
| assert.deepEqual(first.webhookDelivery, { status: 'pending', attempts: 0, lastAttemptAt: null, queuedAt: null, lastError: null }); | ||
| const state = await s.read(); | ||
| assert.deepEqual(state.progress.map(p => [p.eventId, p.currentStatus]), [[first.eventId, 'incoming']]); | ||
| assert.equal((await s.inbox()).pending[0].eventRef.ideaId, first.id); | ||
| assert.equal((await s.flushNotifications()).status, 'queued'); | ||
| assert.deepEqual(calls[0].event, { type: 'decision-desk.event', eventId: first.eventId, contentRevision: 1, deliveryId: first.eventId }); | ||
| const second = await s.putIdea(idea({ requestId: 'idea-2', id: first.id, expectedRevision: 1, title: 'Idee 2' })); | ||
| assert.equal(second.revision, 2); assert.equal(second.category, 'Werkzeug', 'unchanged metadata carries over'); | ||
| assert.equal(second.userPriority, 'high'); | ||
| const third = await s.putIdea(idea({ requestId: 'idea-3', id: first.id, expectedRevision: 2, category: '', userPriority: 'later' })); | ||
| assert.equal(third.category, ''); assert.equal(third.userPriority, 'later'); | ||
| }); | ||
|
|
||
| test('ideas replay by request id and refuse conflicting reuse, stale or unknown revisions, bad metadata and V1 ledgers', async () => { | ||
| const s = await fresh(); const first = await s.putIdea(idea()); const bytes = await readFile(s.file, 'utf8'); | ||
| assert.deepEqual(await s.putIdea(idea()), first); | ||
| await assert.rejects(s.putIdea(idea({ title: 'Anders' })), status(409)); | ||
| await assert.rejects(s.putIdea(idea({ requestId: 'idea-2', id: first.id, expectedRevision: 2 })), status(409)); | ||
| await assert.rejects(s.putIdea(idea({ requestId: 'idea-2', id: 'missing', expectedRevision: 1 })), status(404)); | ||
| for (const bad of [{ category: 'x'.repeat(81) }, { category: 7 }, { userPriority: 'sofort' }, { expectedRevision: 1 }, { title: ' ' }]) | ||
| await assert.rejects(s.putIdea(idea({ requestId: 'idea-bad', ...bad })), status(400), JSON.stringify(bad)); | ||
| assert.equal(await readFile(s.file, 'utf8'), bytes); | ||
| assert.equal((await s.putIdea(idea({ requestId: 'idea-80', category: 'x'.repeat(80) }))).category.length, 80); | ||
| await assert.rejects((await fresh({ v2: false })).putIdea(idea()), status(409)); | ||
| }); | ||
|
|
||
| test('questions are created and revised only against the current revision; imports replay by request id', async () => { | ||
| const s = await fresh({ v2: false }); | ||
| const created = await s.putQuestion(question()); | ||
| assert.equal(created.revision, 1); assert.equal(created.createdAt, created.updatedAt); | ||
| await assert.rejects(s.putQuestion(question({ id: 'E-2', expectedRevision: 1 })), status(409), 'no question for this revision'); | ||
| await assert.rejects(s.putQuestion(question({ title: 'T2' })), status(409), 'revision required for an update'); | ||
| await assert.rejects(s.putQuestion(question({ title: 'T2', expectedRevision: 2 })), status(409)); | ||
| const revised = await s.putQuestion(question({ title: 'T2', expectedRevision: 1 })); | ||
| assert.equal(revised.revision, 2); assert.equal(revised.createdAt, created.createdAt); | ||
| const imported = await s.putQuestion(question({ id: 'E-3', requestId: 'import-1' })); const bytes = await readFile(s.file, 'utf8'); | ||
| assert.deepEqual(await s.putQuestion(question({ id: 'E-3', requestId: 'import-1' })), imported); | ||
| await assert.rejects(s.putQuestion(question({ id: 'E-3', requestId: 'import-1', title: 'Anders' })), status(409)); | ||
| assert.equal(await readFile(s.file, 'utf8'), bytes); | ||
| await assert.rejects(s.putQuestion(question({ options: [opt('a')] })), status(400)); | ||
| }); | ||
|
|
||
| test('a question bound to an idea needs the current idea revision', async () => { | ||
| const s = await fresh(); const i = await s.putIdea(idea()); | ||
| const sourceRef = { kind: 'idea', eventId: i.eventId, ideaId: i.id, ideaRevision: 1 }; | ||
| assert.deepEqual((await s.putQuestion(question({ sourceRef }))).sourceRef, sourceRef); | ||
| await assert.rejects(s.putQuestion(question({ id: 'E-2', sourceRef: { ...sourceRef, ideaId: 'missing' } })), status(404)); | ||
| await s.putIdea(idea({ requestId: 'idea-2', id: i.id, expectedRevision: 1, text: 'Neu' })); | ||
| await assert.rejects(s.putQuestion(question({ id: 'E-2', sourceRef })), status(409), 'outdated idea revision'); | ||
| }); | ||
|
|
||
| test('store.mjs exposes the P1 surface with every operation on one store', async () => { | ||
| assert.deepEqual(Object.keys(surface).sort(), ['DeskError', 'DeskStore', 'compareQuestions', 'latestAnswer', 'migrateState', 'projectQuestionPriority']); | ||
| assert.equal(surface.DeskStore, LedgerStore); | ||
| for (const op of ['read', 'change', 'migrate', 'answer', 'receipt', 'ack', 'pending', 'inbox', 'flushNotifications', 'putProgress', 'putPatch', 'putIdea', 'putQuestion']) | ||
| assert.equal(typeof LedgerStore.prototype[op], 'function', op); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an existing question has a legacy
priorityDetailwithoutsourceRefs, which is accepted becausevalidateQuestiondefaults omitted reference lists to[](model.mjs:157-170), an update that omits replacement priority details selects that raw stored object and this.lengthaccess throws an untypedTypeError. Consequently, an otherwise valid legacy ledger cannot update the question and an HTTP caller would receive an internal error instead of aDeskError; normalize the inherited detail or treat a missingsourceRefsas an empty list.Useful? React with 👍 / 👎.