@danypops/papyrus 0.11.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/domain-tools.ts +108 -52
  4. package/extension/src/index.ts +90 -37
  5. package/extension/src/notes.ts +14 -1
  6. package/extension/src/task-focus-events.ts +57 -0
  7. package/extension/src/tasks.ts +51 -15
  8. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  9. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  10. package/extension/src/tool-rendering/index.ts +107 -0
  11. package/extension/src/tool-rendering/render-model.ts +406 -0
  12. package/package.json +4 -2
  13. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  14. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  15. package/src/adapters/sqlite-artifact-store.ts +20 -11
  16. package/src/adapters/sqlite-discourse-store.ts +325 -0
  17. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  18. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  19. package/src/authority-registry.ts +115 -0
  20. package/src/cli.ts +904 -124
  21. package/src/constants.ts +38 -5
  22. package/src/conversation-journal-service.ts +87 -0
  23. package/src/db.ts +285 -33
  24. package/src/domain/artifact-event.ts +99 -0
  25. package/src/domain/conversation-journal.ts +168 -0
  26. package/src/domain/discourse-store.ts +142 -0
  27. package/src/domain/graph-projection.ts +74 -0
  28. package/src/domain/task-event.ts +4 -0
  29. package/src/domain-services.ts +133 -38
  30. package/src/graph-projection-service.ts +103 -0
  31. package/src/id-migration.ts +200 -0
  32. package/src/module-registry.ts +53 -0
  33. package/src/modules/docs.ts +77 -0
  34. package/src/modules/graph-projection.ts +82 -0
  35. package/src/modules/notes.ts +76 -0
  36. package/src/modules/rules.ts +81 -0
  37. package/src/modules/skills.ts +113 -0
  38. package/src/modules/tasks.ts +164 -0
  39. package/src/ops.ts +142 -15
  40. package/src/ports/artifact-scope-store.ts +20 -0
  41. package/src/ports/artifact-store.ts +10 -5
  42. package/src/ports/conversation-journal-store.ts +17 -0
  43. package/src/ports/graph-projection-store.ts +15 -0
  44. package/src/ports/task-focus-store.ts +62 -20
  45. package/src/service.ts +218 -223
  46. package/src/task-service.ts +70 -38
@@ -0,0 +1,325 @@
1
+ import { DISCOURSE_PARTICIPANT_MAX_COUNT } from "../constants.ts";
2
+ import type { Db } from "../db.ts";
3
+ import { inTransaction } from "../db.ts";
4
+ import {
5
+ appendCommand,
6
+ DISCOURSE_MESSAGE_SUBTYPE,
7
+ DISCOURSE_THREAD_SUBTYPE,
8
+ eventRetention,
9
+ nonNegativeInteger,
10
+ optionalString,
11
+ queryLimit,
12
+ requiredString,
13
+ threadAddress,
14
+ type AppendPostCommand,
15
+ type DiscourseEvent,
16
+ type DiscourseEventType,
17
+ type JsonValue,
18
+ type OpenQuestion,
19
+ type Page,
20
+ type Post,
21
+ type ProjectionRecord,
22
+ type ThreadSummary,
23
+ type TopicSummary,
24
+ } from "../domain/discourse-store.ts";
25
+ import type { AtomicArtifactStore } from "../ports/atomic-artifact-store.ts";
26
+
27
+ interface QuestionColumns {
28
+ questionType?: "question" | "answer";
29
+ responseId?: string;
30
+ targetId?: string;
31
+ }
32
+
33
+ type Row = Record<string, unknown>;
34
+
35
+ function rowString(row: Row, name: string): string {
36
+ const value = row[name];
37
+ if (typeof value !== "string") throw new Error(`invalid persisted ${name}`);
38
+ return value;
39
+ }
40
+
41
+ function rowOptionalString(row: Row, name: string): string | undefined {
42
+ const value = row[name];
43
+ return typeof value === "string" ? value : undefined;
44
+ }
45
+
46
+ function rowNumber(row: Row, name: string): number {
47
+ const value = Number(row[name]);
48
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid persisted ${name}`);
49
+ return value;
50
+ }
51
+
52
+ function parseJson<T>(row: Row, name: string): T {
53
+ return JSON.parse(rowString(row, name)) as T;
54
+ }
55
+
56
+ function postFromRow(row: Row): Post {
57
+ return {
58
+ id: rowString(row, "id"),
59
+ sequence: rowNumber(row, "sequence"),
60
+ operationId: rowString(row, "operation_id"),
61
+ forumId: rowString(row, "forum_id"),
62
+ topicId: rowString(row, "topic_id"),
63
+ threadId: rowString(row, "thread_id"),
64
+ authorId: rowString(row, "author_id"),
65
+ content: parseJson<JsonValue>(row, "content_json"),
66
+ timestamp: rowNumber(row, "timestamp"),
67
+ references: parseJson(row, "references_json"),
68
+ ...(rowOptionalString(row, "correlation_id") ? { correlationId: rowString(row, "correlation_id") } : {}),
69
+ ...(rowOptionalString(row, "causation_id") ? { causationId: rowString(row, "causation_id") } : {}),
70
+ ...(rowOptionalString(row, "reply_to_post_id") ? { replyToPostId: rowString(row, "reply_to_post_id") } : {}),
71
+ };
72
+ }
73
+
74
+ function page<T>(items: T[], limit: number, sequenceOf?: (item: T) => number): Page<T> {
75
+ const truncated = items.length > limit;
76
+ const selected = items.slice(0, limit);
77
+ const last = selected.at(-1);
78
+ return {
79
+ items: selected,
80
+ truncated,
81
+ completeness: truncated ? "truncated" : "complete",
82
+ ...(truncated && last !== undefined && sequenceOf ? { nextSequence: sequenceOf(last) } : {}),
83
+ };
84
+ }
85
+
86
+ function questionColumns(content: JsonValue): QuestionColumns {
87
+ if (typeof content !== "object" || content === null || Array.isArray(content)) return {};
88
+ const type = content["type"];
89
+ const responseId = content["responseId"];
90
+ const targetId = content["targetId"];
91
+ if ((type !== "question" && type !== "answer") || typeof responseId !== "string" || responseId.length === 0) return {};
92
+ return { questionType: type, responseId, ...(typeof targetId === "string" && targetId.length > 0 ? { targetId } : {}) };
93
+ }
94
+
95
+ function eventsFor(command: AppendPostCommand, postId: string, timestamp: number, firstSequence: number): DiscourseEvent[] {
96
+ const question = questionColumns(command.content);
97
+ const metadata: Array<{ type: DiscourseEventType; responseId?: string }> = [
98
+ { type: "post-added" },
99
+ { type: "thread-changed" },
100
+ ];
101
+ if (question.questionType) {
102
+ metadata.push({
103
+ type: question.questionType === "question" ? "question-opened" : "question-answered",
104
+ responseId: question.responseId,
105
+ });
106
+ }
107
+ return metadata.map((event, index) => ({
108
+ schemaVersion: "discourse.event.v1",
109
+ type: event.type,
110
+ sequence: firstSequence + index,
111
+ timestamp,
112
+ forumId: command.forumId,
113
+ topicId: command.topicId,
114
+ threadId: command.threadId,
115
+ postId,
116
+ operationId: command.operationId,
117
+ ...(command.correlationId ? { correlationId: command.correlationId } : {}),
118
+ ...(command.causationId ? { causationId: command.causationId } : {}),
119
+ ...(event.responseId ? { responseId: event.responseId } : {}),
120
+ }));
121
+ }
122
+
123
+ function bodyFor(content: JsonValue): string {
124
+ return typeof content === "string" ? content : JSON.stringify(content, null, 2);
125
+ }
126
+
127
+ /** Durable graph adapter used only behind the Discourse application mutation boundary. */
128
+ export class SQLiteDiscourseStore {
129
+ constructor(private readonly db: Db, private readonly artifacts: AtomicArtifactStore) {}
130
+
131
+ execute(input: Record<string, unknown>): unknown {
132
+ const action = requiredString(input["action"], "action");
133
+ const storeId = requiredString(input["store_id"], "store_id");
134
+ switch (action) {
135
+ case "append":
136
+ return this.append(
137
+ storeId,
138
+ appendCommand(input["command"]),
139
+ requiredString(input["post_id"], "post_id"),
140
+ nonNegativeInteger(input["timestamp"], "timestamp"),
141
+ eventRetention(input["event_retention"]),
142
+ );
143
+ case "read_thread":
144
+ return this.readThread(storeId, input);
145
+ case "list_topics":
146
+ return this.listTopics(storeId, requiredString(input["forumId"], "forumId"), queryLimit(input["limit"]));
147
+ case "list_threads":
148
+ return this.listThreads(storeId, requiredString(input["forumId"], "forumId"), requiredString(input["topicId"], "topicId"), queryLimit(input["limit"]));
149
+ case "open_questions":
150
+ return this.openQuestions(storeId, optionalString(input["forumId"], "forumId"), optionalString(input["targetId"], "targetId"), queryLimit(input["limit"]));
151
+ case "replay":
152
+ return this.replay(storeId, nonNegativeInteger(input["after_sequence"], "after_sequence"), queryLimit(input["limit"]));
153
+ case "snapshot":
154
+ return this.snapshot(storeId, input);
155
+ case "acknowledge":
156
+ return this.acknowledge(storeId, requiredString(input["consumer_id"], "consumer_id"), nonNegativeInteger(input["sequence"], "sequence"));
157
+ case "consumer_cursor":
158
+ return this.cursor("discourse_cursors", "consumer_id", storeId, requiredString(input["consumer_id"], "consumer_id"));
159
+ case "read_projection_outbox":
160
+ return this.projectionOutbox(storeId, requiredString(input["projection_id"], "projection_id"), queryLimit(input["limit"]));
161
+ case "acknowledge_projection":
162
+ this.acknowledgeProjection(storeId, requiredString(input["projection_id"], "projection_id"), nonNegativeInteger(input["sequence"], "sequence"));
163
+ return { ok: true };
164
+ case "projection_checkpoint":
165
+ return this.cursor("discourse_projection_cursors", "projection_id", storeId, requiredString(input["projection_id"], "projection_id"));
166
+ case "projection_pending":
167
+ return this.projectionPending(storeId, requiredString(input["projection_id"], "projection_id"));
168
+ case "latest_post_sequence":
169
+ return this.maximum(storeId, "discourse_posts");
170
+ default:
171
+ throw new Error(`unknown discourse store action "${action}"`);
172
+ }
173
+ }
174
+
175
+ private append(storeId: string, command: AppendPostCommand, postId: string, timestamp: number, retention: number): { post: Post; replayed: boolean; events: DiscourseEvent[] } {
176
+ return inTransaction(this.db, () => {
177
+ const prior = this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND operation_id = ?").get(storeId, command.operationId) as Row | null;
178
+ const commandJson = JSON.stringify(command);
179
+ if (prior) {
180
+ if (rowString(prior, "command_json") !== commandJson) throw new Error(`operation conflict: ${command.operationId}`);
181
+ return { post: postFromRow(prior), replayed: true, events: [] };
182
+ }
183
+ for (const reference of command.references ?? []) {
184
+ const artifact = this.artifacts.get(reference.id);
185
+ if (!artifact || artifact.kind !== reference.kind) throw new Error(`artifact reference not verified: ${reference.kind}:${reference.id}`);
186
+ }
187
+ let replyArtifactId: string | undefined;
188
+ if (command.replyToPostId) {
189
+ const parent = this.db.prepare("SELECT forum_id, topic_id, thread_id, artifact_id FROM discourse_posts WHERE store_id = ? AND id = ?").get(storeId, command.replyToPostId) as Row | null;
190
+ if (!parent) throw new Error(`reply target not found: ${command.replyToPostId}`);
191
+ if (rowString(parent, "forum_id") !== command.forumId || rowString(parent, "topic_id") !== command.topicId || rowString(parent, "thread_id") !== command.threadId) {
192
+ throw new Error("reply target must belong to the same thread");
193
+ }
194
+ replyArtifactId = rowString(parent, "artifact_id");
195
+ }
196
+ const firstSequence = this.maximum(storeId, "discourse_events") + 1;
197
+ const events = eventsFor(command, postId, timestamp, firstSequence);
198
+ const threadArtifactId = this.ensureThread(storeId, command);
199
+ const message = this.artifacts.create({
200
+ kind: "doc",
201
+ title: `${command.authorId} · ${command.threadId} · ${firstSequence}`,
202
+ status: "active",
203
+ subtype: DISCOURSE_MESSAGE_SUBTYPE,
204
+ body: bodyFor(command.content),
205
+ extra: {
206
+ storeId, postId, sequence: firstSequence, operationId: command.operationId,
207
+ forumId: command.forumId, topicId: command.topicId, threadId: command.threadId,
208
+ authorId: command.authorId, timestamp,
209
+ },
210
+ });
211
+ const question = questionColumns(command.content);
212
+ this.db.prepare(`INSERT INTO discourse_posts (
213
+ store_id, sequence, id, artifact_id, operation_id, command_json, forum_id, topic_id, thread_id,
214
+ author_id, content_json, timestamp, correlation_id, causation_id, reply_to_post_id, references_json,
215
+ question_type, response_id, target_id
216
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
217
+ storeId, firstSequence, postId, message.id, command.operationId, commandJson,
218
+ command.forumId, command.topicId, command.threadId, command.authorId, JSON.stringify(command.content), timestamp,
219
+ command.correlationId ?? null, command.causationId ?? null, command.replyToPostId ?? null,
220
+ JSON.stringify(command.references ?? []), question.questionType ?? null, question.responseId ?? null, question.targetId ?? null,
221
+ );
222
+ this.artifacts.link({ from: threadArtifactId, relation: "contains", to: message.id });
223
+ this.artifacts.link({ from: message.id, relation: "part_of", to: threadArtifactId });
224
+ if (replyArtifactId) this.artifacts.link({ from: message.id, relation: "reply_to", to: replyArtifactId });
225
+ for (const reference of command.references ?? []) this.artifacts.link({ from: message.id, relation: "discusses", to: reference.id });
226
+ for (const event of events) {
227
+ this.db.prepare("INSERT INTO discourse_events (store_id, sequence, event_json) VALUES (?, ?, ?)").run(storeId, event.sequence, JSON.stringify(event));
228
+ }
229
+ const latest = events.at(-1);
230
+ if (!latest) throw new Error("append produced no events");
231
+ this.db.prepare("DELETE FROM discourse_events WHERE store_id = ? AND sequence <= ?").run(storeId, latest.sequence - retention);
232
+ return { post: postFromRow(this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND id = ?").get(storeId, postId) as Row), replayed: false, events };
233
+ });
234
+ }
235
+
236
+ private ensureThread(storeId: string, address: AppendPostCommand): string {
237
+ const existing = this.db.prepare("SELECT artifact_id FROM discourse_threads WHERE store_id = ? AND forum_id = ? AND topic_id = ? AND thread_id = ?").get(storeId, address.forumId, address.topicId, address.threadId) as Row | null;
238
+ if (existing) return rowString(existing, "artifact_id");
239
+ const thread = this.artifacts.create({
240
+ kind: "doc", title: address.threadId, status: "active", subtype: DISCOURSE_THREAD_SUBTYPE,
241
+ extra: { storeId, forumId: address.forumId, topicId: address.topicId, threadId: address.threadId },
242
+ });
243
+ this.db.prepare("INSERT INTO discourse_threads (store_id, forum_id, topic_id, thread_id, artifact_id) VALUES (?, ?, ?, ?, ?)").run(storeId, address.forumId, address.topicId, address.threadId, thread.id);
244
+ return thread.id;
245
+ }
246
+
247
+ private readThread(storeId: string, input: Record<string, unknown>): Page<Post> {
248
+ const address = threadAddress(input);
249
+ const limit = queryLimit(input["limit"]);
250
+ const after = input["afterSequence"] === undefined ? 0 : nonNegativeInteger(input["afterSequence"], "afterSequence");
251
+ const rows = this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND forum_id = ? AND topic_id = ? AND thread_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(storeId, address.forumId, address.topicId, address.threadId, after, limit + 1) as Row[];
252
+ return page(rows.map(postFromRow), limit, (post) => post.sequence);
253
+ }
254
+
255
+ private listTopics(storeId: string, forumId: string, limit: number): Page<TopicSummary> {
256
+ const rows = this.db.prepare("SELECT forum_id, topic_id, COUNT(DISTINCT thread_id) AS thread_count, COUNT(*) AS post_count, MAX(timestamp) AS last_activity FROM discourse_posts WHERE store_id = ? AND forum_id = ? GROUP BY forum_id, topic_id ORDER BY topic_id LIMIT ?").all(storeId, forumId, limit + 1) as Row[];
257
+ return page(rows.map((row) => ({ forumId: rowString(row, "forum_id"), topicId: rowString(row, "topic_id"), threadCount: rowNumber(row, "thread_count"), postCount: rowNumber(row, "post_count"), lastActivity: rowNumber(row, "last_activity") })), limit);
258
+ }
259
+
260
+ private listThreads(storeId: string, forumId: string, topicId: string, limit: number): Page<ThreadSummary> {
261
+ const rows = this.db.prepare("SELECT forum_id, topic_id, thread_id, COUNT(*) AS post_count, MAX(timestamp) AS last_activity FROM discourse_posts WHERE store_id = ? AND forum_id = ? AND topic_id = ? GROUP BY forum_id, topic_id, thread_id ORDER BY thread_id LIMIT ?").all(storeId, forumId, topicId, limit + 1) as Row[];
262
+ return page(rows.map((row) => {
263
+ const threadId = rowString(row, "thread_id");
264
+ const participants = this.db.prepare("SELECT DISTINCT author_id FROM discourse_posts WHERE store_id = ? AND forum_id = ? AND topic_id = ? AND thread_id = ? ORDER BY author_id LIMIT ?").all(storeId, forumId, topicId, threadId, DISCOURSE_PARTICIPANT_MAX_COUNT) as Row[];
265
+ return { forumId, topicId, threadId, postCount: rowNumber(row, "post_count"), participantIds: participants.map((entry) => rowString(entry, "author_id")), lastActivity: rowNumber(row, "last_activity") };
266
+ }), limit);
267
+ }
268
+
269
+ private openQuestions(storeId: string, forumId: string | undefined, targetId: string | undefined, limit: number): Page<OpenQuestion> {
270
+ const rows = this.db.prepare("SELECT p.* FROM discourse_posts p WHERE p.store_id = ? AND p.question_type = 'question' AND (? IS NULL OR p.forum_id = ?) AND (? IS NULL OR p.target_id IS NULL OR p.target_id = ?) AND NOT EXISTS (SELECT 1 FROM discourse_posts a WHERE a.store_id = p.store_id AND a.question_type = 'answer' AND a.response_id = p.response_id) ORDER BY p.sequence LIMIT ?").all(storeId, forumId ?? null, forumId ?? null, targetId ?? null, targetId ?? null, limit + 1) as Row[];
271
+ return page(rows.map((row) => ({ responseId: rowString(row, "response_id"), post: postFromRow(row) })), limit, (question) => question.post.sequence);
272
+ }
273
+
274
+ private replay(storeId: string, afterSequence: number, limit: number): { events: DiscourseEvent[]; retainedFromSequence: number; latestSequence: number; expired: boolean; truncated: boolean } {
275
+ const bounds = this.db.prepare("SELECT COALESCE(MIN(sequence), 0) AS minimum, COALESCE(MAX(sequence), 0) AS maximum FROM discourse_events WHERE store_id = ?").get(storeId) as Row;
276
+ const retainedFromSequence = rowNumber(bounds, "minimum");
277
+ const latestSequence = rowNumber(bounds, "maximum");
278
+ const expired = retainedFromSequence > 0 && afterSequence > 0 && afterSequence < retainedFromSequence - 1;
279
+ const rows = expired ? [] : this.db.prepare("SELECT event_json FROM discourse_events WHERE store_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(storeId, afterSequence, limit + 1) as Row[];
280
+ const events = rows.map((row) => parseJson<DiscourseEvent>(row, "event_json"));
281
+ return { events: events.slice(0, limit), retainedFromSequence, latestSequence, expired, truncated: events.length > limit };
282
+ }
283
+
284
+ private snapshot(storeId: string, input: Record<string, unknown>): { posts: Page<Post>; throughSequence: number } {
285
+ const limit = queryLimit(input["limit"]);
286
+ const after = input["afterSequence"] === undefined ? 0 : nonNegativeInteger(input["afterSequence"], "afterSequence");
287
+ const forumId = optionalString(input["forumId"], "forumId");
288
+ const rows = this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND sequence > ? AND (? IS NULL OR forum_id = ?) ORDER BY sequence LIMIT ?").all(storeId, after, forumId ?? null, forumId ?? null, limit + 1) as Row[];
289
+ return { posts: page(rows.map(postFromRow), limit, (post) => post.sequence), throughSequence: this.maximum(storeId, "discourse_events") };
290
+ }
291
+
292
+ private acknowledge(storeId: string, consumerId: string, sequence: number): number {
293
+ const latest = this.maximum(storeId, "discourse_events");
294
+ if (sequence > latest) throw new Error(`cannot acknowledge future sequence ${sequence}`);
295
+ this.db.prepare("INSERT INTO discourse_cursors (store_id, consumer_id, sequence) VALUES (?, ?, ?) ON CONFLICT(store_id, consumer_id) DO UPDATE SET sequence = MAX(sequence, excluded.sequence)").run(storeId, consumerId, sequence);
296
+ return this.cursor("discourse_cursors", "consumer_id", storeId, consumerId);
297
+ }
298
+
299
+ private projectionOutbox(storeId: string, projectionId: string, limit: number): ProjectionRecord[] {
300
+ const checkpoint = this.cursor("discourse_projection_cursors", "projection_id", storeId, projectionId);
301
+ return (this.db.prepare("SELECT * FROM discourse_posts WHERE store_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(storeId, checkpoint, limit) as Row[]).map((row) => {
302
+ const post = postFromRow(row);
303
+ return { sequence: post.sequence, post };
304
+ });
305
+ }
306
+
307
+ private acknowledgeProjection(storeId: string, projectionId: string, sequence: number): void {
308
+ if (sequence > this.maximum(storeId, "discourse_posts")) throw new Error(`cannot acknowledge future projection sequence ${sequence}`);
309
+ this.db.prepare("INSERT INTO discourse_projection_cursors (store_id, projection_id, sequence) VALUES (?, ?, ?) ON CONFLICT(store_id, projection_id) DO UPDATE SET sequence = MAX(sequence, excluded.sequence)").run(storeId, projectionId, sequence);
310
+ }
311
+
312
+ private projectionPending(storeId: string, projectionId: string): number {
313
+ const checkpoint = this.cursor("discourse_projection_cursors", "projection_id", storeId, projectionId);
314
+ return rowNumber(this.db.prepare("SELECT COUNT(*) AS value FROM discourse_posts WHERE store_id = ? AND sequence > ?").get(storeId, checkpoint) as Row, "value");
315
+ }
316
+
317
+ private cursor(table: "discourse_cursors" | "discourse_projection_cursors", column: "consumer_id" | "projection_id", storeId: string, id: string): number {
318
+ const row = this.db.prepare(`SELECT sequence FROM ${table} WHERE store_id = ? AND ${column} = ?`).get(storeId, id) as Row | null;
319
+ return row ? rowNumber(row, "sequence") : 0;
320
+ }
321
+
322
+ private maximum(storeId: string, table: "discourse_events" | "discourse_posts"): number {
323
+ return rowNumber(this.db.prepare(`SELECT COALESCE(MAX(sequence), 0) AS value FROM ${table} WHERE store_id = ?`).get(storeId) as Row, "value");
324
+ }
325
+ }
@@ -0,0 +1,41 @@
1
+ import type { Db } from "../db.ts";
2
+ import { inTransaction } from "../db.ts";
3
+ import type { ProjectionCheckpoint } from "../domain/graph-projection.ts";
4
+ import type { GraphProjectionStore } from "../ports/graph-projection-store.ts";
5
+
6
+ export class SQLiteGraphProjectionStore implements GraphProjectionStore {
7
+ constructor(private readonly db: Db) {}
8
+
9
+ getCheckpoint(producerId: string): ProjectionCheckpoint | null {
10
+ const row = this.db.prepare("SELECT producer_id, last_sequence, last_batch_id, applied_at FROM graph_projection_checkpoints WHERE producer_id = ?").get(producerId) as
11
+ | { producer_id: string; last_sequence: number; last_batch_id: string; applied_at: string }
12
+ | null;
13
+ if (row == null) return null;
14
+ return { producerId: row.producer_id, lastSequence: row.last_sequence, lastBatchId: row.last_batch_id, appliedAt: row.applied_at };
15
+ }
16
+
17
+ resolveIdentity(producerId: string, externalId: string): string | undefined {
18
+ const row = this.db.prepare("SELECT artifact_id FROM graph_projection_identities WHERE producer_id = ? AND external_id = ?").get(producerId, externalId) as
19
+ | { artifact_id: string }
20
+ | null;
21
+ return row?.artifact_id;
22
+ }
23
+
24
+ recordIdentity(producerId: string, externalId: string, artifactId: string): void {
25
+ inTransaction(this.db, () => {
26
+ this.db.prepare(`
27
+ INSERT INTO graph_projection_identities (producer_id, external_id, artifact_id) VALUES (?, ?, ?)
28
+ ON CONFLICT(producer_id, external_id) DO UPDATE SET artifact_id = excluded.artifact_id
29
+ `).run(producerId, externalId, artifactId);
30
+ });
31
+ }
32
+
33
+ commitCheckpoint(checkpoint: ProjectionCheckpoint): void {
34
+ inTransaction(this.db, () => {
35
+ this.db.prepare(`
36
+ INSERT INTO graph_projection_checkpoints (producer_id, last_sequence, last_batch_id, applied_at) VALUES (?, ?, ?, ?)
37
+ ON CONFLICT(producer_id) DO UPDATE SET last_sequence = excluded.last_sequence, last_batch_id = excluded.last_batch_id, applied_at = excluded.applied_at
38
+ `).run(checkpoint.producerId, checkpoint.lastSequence, checkpoint.lastBatchId, checkpoint.appliedAt);
39
+ });
40
+ }
41
+ }
@@ -1,43 +1,62 @@
1
+ import { TASK_FOCUS_MAX_SCOPES } from "../constants.ts";
1
2
  import type { Db } from "../db.ts";
2
3
  import { inTransaction } from "../db.ts";
3
- import type { TaskFocusState, TaskFocusStatus, TaskFocusStore } from "../ports/task-focus-store.ts";
4
+ import { normalizeFocusScope, type TaskFocusState, type TaskFocusStatus, type TaskFocusStore } from "../ports/task-focus-store.ts";
4
5
 
5
6
  export class SQLiteTaskFocusStore implements TaskFocusStore {
6
7
  constructor(private readonly db: Db) {}
7
8
 
8
- get(): TaskFocusState | undefined {
9
- const row = this.db.prepare("SELECT task_id, status, pause_reason, updated_at FROM task_focus WHERE scope = 'global'").get() as
9
+ get(scope?: string): TaskFocusState | undefined {
10
+ const row = this.db.prepare("SELECT task_id, status, pause_reason, updated_at FROM task_focus WHERE scope = ?").get(normalizeFocusScope(scope)) as
10
11
  | { task_id: string; status: TaskFocusStatus; pause_reason: string | null; updated_at: string }
11
12
  | null;
12
13
  return row ? { taskId: row.task_id, status: row.status, updatedAt: row.updated_at, ...(row.pause_reason ? { pauseReason: row.pause_reason } : {}) } : undefined;
13
14
  }
14
15
 
15
- set(taskId: string): TaskFocusState { return this.write(taskId, "active"); }
16
- pause(taskId: string, reason?: string): TaskFocusState { return this.transition(taskId, "active", "paused", reason); }
17
- unpause(taskId: string): TaskFocusState { return this.transition(taskId, "paused", "active"); }
16
+ set(taskId: string, scope?: string): TaskFocusState { return this.write(taskId, "active", scope); }
17
+ pause(taskId: string, reason?: string, scope?: string): TaskFocusState { return this.transition(taskId, "active", "paused", reason, scope); }
18
+ unpause(taskId: string, scope?: string): TaskFocusState { return this.transition(taskId, "paused", "active", undefined, scope); }
18
19
 
19
- clear(taskId?: string): void {
20
+ clear(taskId?: string, scope?: string): void {
21
+ const key = normalizeFocusScope(scope);
20
22
  inTransaction(this.db, () => {
21
- if (taskId === undefined) this.db.prepare("DELETE FROM task_focus WHERE scope = 'global'").run();
22
- else this.db.prepare("DELETE FROM task_focus WHERE scope = 'global' AND task_id = ?").run(taskId);
23
+ if (taskId === undefined) this.db.prepare("DELETE FROM task_focus WHERE scope = ?").run(key);
24
+ else this.db.prepare("DELETE FROM task_focus WHERE scope = ? AND task_id = ?").run(key, taskId);
23
25
  });
24
26
  }
25
27
 
26
- private transition(taskId: string, expected: TaskFocusStatus, status: TaskFocusStatus, reason?: string): TaskFocusState {
27
- const current = this.get();
28
+ clearEverywhere(taskId: string): void {
29
+ inTransaction(this.db, () => {
30
+ this.db.prepare("DELETE FROM task_focus WHERE task_id = ?").run(taskId);
31
+ });
32
+ }
33
+
34
+ private transition(taskId: string, expected: TaskFocusStatus, status: TaskFocusStatus, reason: string | undefined, scope: string | undefined): TaskFocusState {
35
+ const current = this.get(scope);
28
36
  if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
29
37
  if (current.status !== expected) throw new Error(`focus is ${current.status}, expected ${expected}`);
30
- return this.write(taskId, status, reason);
38
+ return this.write(taskId, status, scope, reason);
39
+ }
40
+
41
+ /** Bounds distinct concurrent focus scopes (sessions); evicts the least-recently-updated scope beyond the cap. */
42
+ private evictOldestBeyondCap(key: string): void {
43
+ const exists = this.db.prepare("SELECT 1 FROM task_focus WHERE scope = ?").get(key);
44
+ if (exists) return;
45
+ const count = (this.db.prepare("SELECT COUNT(*) AS count FROM task_focus").get() as { count: number }).count;
46
+ if (count < TASK_FOCUS_MAX_SCOPES) return;
47
+ this.db.exec("DELETE FROM task_focus WHERE scope = (SELECT scope FROM task_focus ORDER BY updated_at ASC LIMIT 1)");
31
48
  }
32
49
 
33
- private write(taskId: string, status: TaskFocusStatus, pauseReason?: string): TaskFocusState {
50
+ private write(taskId: string, status: TaskFocusStatus, scope: string | undefined, pauseReason?: string): TaskFocusState {
51
+ const key = normalizeFocusScope(scope);
34
52
  const updatedAt = new Date().toISOString();
35
53
  inTransaction(this.db, () => {
54
+ this.evictOldestBeyondCap(key);
36
55
  this.db.prepare(`
37
56
  INSERT INTO task_focus (scope, task_id, status, pause_reason, updated_at)
38
- VALUES ('global', ?, ?, ?, ?)
57
+ VALUES (?, ?, ?, ?, ?)
39
58
  ON CONFLICT(scope) DO UPDATE SET task_id = excluded.task_id, status = excluded.status, pause_reason = excluded.pause_reason, updated_at = excluded.updated_at
40
- `).run(taskId, status, pauseReason ?? null, updatedAt);
59
+ `).run(key, taskId, status, pauseReason ?? null, updatedAt);
41
60
  });
42
61
  return { taskId, status, updatedAt, ...(pauseReason ? { pauseReason } : {}) };
43
62
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * authority-registry.ts — step 4 of the incremental refactor in
3
+ * reducing-papyrus-consumer-change-amplification-with-modules--pvdo.
4
+ *
5
+ * Subtype/relation ownership guards (isDiscourseSubtype, NOTE_SUBTYPE, task-kind checks)
6
+ * were previously re-implemented at every write call site across src/service.ts and
7
+ * src/domain-services.ts. This is the one deep enforcement point: a claim expresses
8
+ * which module owns which artifact kind/subtype or relation and what message a
9
+ * non-owner gets for a given action; AuthorizedArtifactWriter enforces claims for the
10
+ * mechanical link/unlink/status paths where the target artifact's persisted kind/subtype
11
+ * is unambiguous. Create is intentionally NOT wrapped transparently here — template
12
+ * resolution (an artifact-template's declared targetKind/defaults.subtype) determines
13
+ * the effective kind/subtype before a claim can be checked, and callers already resolve
14
+ * that themselves; they call registry.requireArtifactAllowed(...) directly with the
15
+ * resolved kind/subtype instead.
16
+ *
17
+ * This module has no domain knowledge of its own (no Discourse/Notes/Tasks awareness) —
18
+ * claims are constructed at the composition root (src/service.ts) where that knowledge
19
+ * already lives, matching "the core has generic registries" from the decision doc.
20
+ */
21
+ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
22
+ import type { Artifact, ArtifactLink } from "./domain/artifact.ts";
23
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
24
+
25
+ export type ArtifactAction = "create" | "link" | "status";
26
+
27
+ export interface AuthorityClaim {
28
+ /** Module id that owns this kind/subtype/relation, e.g. "discourse", "notes", "tasks". */
29
+ readonly owner: string;
30
+ /** kind may be undefined at a call site that has not yet resolved an artifact's effective kind (e.g. pre-template-resolution). */
31
+ matchesArtifact(kind: string | undefined, subtype: string | undefined): boolean;
32
+ matchesRelation?(relation: string): boolean;
33
+ /**
34
+ * If provided, this claim is only enforced for the given actions — e.g. Task ownership of
35
+ * kind="task" is only enforced for status changes; artifact.create redirects kind="task" to
36
+ * tasks.create rather than rejecting it, so the claim must not match the "create" action.
37
+ * Omit to apply to every action.
38
+ */
39
+ appliesToAction?(action: ArtifactAction): boolean;
40
+ /** Exact rejection message for a non-owner attempting `action`. Must match the historical per-action wording. */
41
+ denyMessage(action: ArtifactAction): string;
42
+ }
43
+
44
+ /** O(N) claims, O(N) lookup (N is the number of registered domains, not artifacts — small and fixed at boot). */
45
+ export class AuthorityRegistry {
46
+ private readonly claims: AuthorityClaim[] = [];
47
+
48
+ claim(claim: AuthorityClaim): void {
49
+ this.claims.push(claim);
50
+ }
51
+
52
+ claimAll(claims: readonly AuthorityClaim[]): void {
53
+ for (const entry of claims) this.claim(entry);
54
+ }
55
+
56
+ claimForArtifact(kind: string | undefined, subtype: string | undefined, action: ArtifactAction): AuthorityClaim | undefined {
57
+ return this.claims.find((entry) => (entry.appliesToAction?.(action) ?? true) && entry.matchesArtifact(kind, subtype));
58
+ }
59
+
60
+ claimForRelation(relation: string, action: ArtifactAction): AuthorityClaim | undefined {
61
+ return this.claims.find((entry) => (entry.appliesToAction?.(action) ?? true) && entry.matchesRelation?.(relation) === true);
62
+ }
63
+
64
+ /** Throws the owning claim's message if kind/subtype is claimed by a module other than `caller`. No-op if unclaimed or caller is the owner. */
65
+ requireArtifactAllowed(kind: string | undefined, subtype: string | undefined, action: ArtifactAction, caller: string): void {
66
+ const claim = this.claimForArtifact(kind, subtype, action);
67
+ if (claim && claim.owner !== caller) throw new Error(claim.denyMessage(action));
68
+ }
69
+
70
+ requireRelationAllowed(relation: string, action: ArtifactAction, caller: string): void {
71
+ const claim = this.claimForRelation(relation, action);
72
+ if (claim && claim.owner !== caller) throw new Error(claim.denyMessage(action));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * A scoped write path bound to one caller identity. Every mutating call re-checks the
78
+ * *persisted* kind/subtype of the artifacts involved (via a get() read, not a cached
79
+ * assumption), so a caller cannot bypass a claim by acting on an id whose current
80
+ * ownership it hasn't verified.
81
+ */
82
+ export class AuthorizedArtifactWriter {
83
+ constructor(
84
+ private readonly store: ArtifactStore,
85
+ private readonly registry: AuthorityRegistry,
86
+ private readonly caller: string,
87
+ ) {}
88
+
89
+ link(link: ArtifactLink, context?: ArtifactEventContext): void {
90
+ this.checkLink(link);
91
+ this.store.link(link, context);
92
+ }
93
+
94
+ unlink(link: ArtifactLink, context?: ArtifactEventContext): boolean {
95
+ this.checkLink(link);
96
+ return this.store.unlink(link, context);
97
+ }
98
+
99
+ setStatus(id: string, status: string, context?: ArtifactEventContext): Artifact | null {
100
+ const artifact = this.store.get(id);
101
+ if (artifact) this.registry.requireArtifactAllowed(artifact.kind, artifact.subtype, "status", this.caller);
102
+ return this.store.setStatus(id, status, context);
103
+ }
104
+
105
+ /** Exposed standalone so a caller that needs custom branching between claim-checked and redirect
106
+ * paths (e.g. graph.link routing depends_on between two Tasks through Tasks.depend for cycle
107
+ * safety) can still run the exact same check before deciding which path to take. */
108
+ checkLink(link: ArtifactLink): void {
109
+ this.registry.requireRelationAllowed(link.relation, "link", this.caller);
110
+ const from = this.store.get(link.from);
111
+ const to = this.store.get(link.to);
112
+ if (from) this.registry.requireArtifactAllowed(from.kind, from.subtype, "link", this.caller);
113
+ if (to) this.registry.requireArtifactAllowed(to.kind, to.subtype, "link", this.caller);
114
+ }
115
+ }