@danypops/papyrus 0.14.0 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -25,7 +25,7 @@ import type { ArtifactStore } from "./ports/artifact-store.ts";
25
25
  export type ArtifactAction = "create" | "link" | "status";
26
26
 
27
27
  export interface AuthorityClaim {
28
- /** Module id that owns this kind/subtype/relation, e.g. "discourse", "notes", "tasks". */
28
+ /** Module id that owns this kind/subtype/relation, e.g. "notes", "tasks". */
29
29
  readonly owner: string;
30
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
31
  matchesArtifact(kind: string | undefined, subtype: string | undefined): boolean;
package/src/cli.ts CHANGED
@@ -71,7 +71,6 @@ const USAGE = `Usage:
71
71
  papyrus migrate-ids mirror [--db <path>] --out <mirror-path> [--json]
72
72
  papyrus migrate-ids validate --mirror <mirror-path> [--idmap <path>] [--json]
73
73
  papyrus migrate-ids promote --mirror <mirror-path> [--db <path>] [--idmap <path>] [--force] [--json]
74
- papyrus discourse store <action> --store-id <id> [--input-json <json>] [--json]
75
74
  papyrus graph link <from> <relation> <to> [--json]
76
75
  papyrus graph unlink <from> <relation> <to> [--json]
77
76
  papyrus graph tree <id> [--depth <n>] [--max-nodes <n>] [--json]
@@ -308,36 +307,6 @@ export function runIdMigrationCli(args: string[]): string {
308
307
  throw new Error("migrate-ids requires one of: mirror, validate, promote");
309
308
  }
310
309
 
311
- export async function runDiscourseCli(args: string[], client: TaskCliClient): Promise<string> {
312
- const json = args.includes("--json");
313
- const positional: string[] = [];
314
- let storeId: string | undefined;
315
- let operationInput: Record<string, unknown> = {};
316
- for (let index = 0; index < args.length; index++) {
317
- const argument = args[index]!;
318
- if (argument === "--json") continue;
319
- if (argument === "--store-id" || argument === "--input-json") {
320
- const value = args[++index];
321
- if (!value) throw new Error(`${argument} requires a value`);
322
- if (argument === "--store-id") storeId = value;
323
- else {
324
- const parsed = JSON.parse(value) as unknown;
325
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("--input-json must be a JSON object");
326
- operationInput = parsed as Record<string, unknown>;
327
- }
328
- continue;
329
- }
330
- if (argument.startsWith("--")) throw new Error(`unknown discourse option ${argument}`);
331
- positional.push(argument);
332
- }
333
- if (positional.length !== 2 || positional[0] !== "store") throw new Error("discourse requires `store <action>`");
334
- if (!storeId) throw new Error("discourse store requires --store-id");
335
- const result = await client.call<Record<string, unknown>, unknown>("discourse.store", {
336
- action: positional[1], store_id: storeId, ...operationInput,
337
- });
338
- return json ? JSON.stringify(result) : `Discourse store ${positional[1]} completed.`;
339
- }
340
-
341
310
  export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
342
311
  const json = args.includes("--json");
343
312
  const positional: string[] = [];
@@ -1324,11 +1293,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1324
1293
  console.log(await runTaskCli(args.slice(1), client));
1325
1294
  return;
1326
1295
  }
1327
- if (command === "discourse") {
1328
- const client = await connectPapyrusClient();
1329
- console.log(await runDiscourseCli(args.slice(1), client));
1330
- return;
1331
- }
1332
1296
  if (command === "skills") {
1333
1297
  const client = await connectPapyrusClient();
1334
1298
  console.log(await runSkillCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,14 +7,9 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 11;
10
+ export const SQLITE_SCHEMA_VERSION = 12;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
- /** Bounded forum persistence behind the Discourse mutation authority. */
13
- export const DISCOURSE_QUERY_MAX_LIMIT = 100;
14
- export const DISCOURSE_CONTENT_MAX_BYTES = 65_536;
15
- export const DISCOURSE_EVENT_RETENTION_DEFAULT = 1_000;
16
- export const DISCOURSE_EVENT_RETENTION_MAX = 10_000;
17
- export const DISCOURSE_PARTICIPANT_MAX_COUNT = 100;
12
+
18
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
19
14
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
20
15
  export const GATE_COMMAND_TIMEOUT_MS = 30_000;
package/src/db.ts CHANGED
@@ -147,67 +147,6 @@ CREATE TABLE IF NOT EXISTS task_views (
147
147
  updated_at TEXT NOT NULL,
148
148
  CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
149
149
  );
150
- CREATE TABLE IF NOT EXISTS discourse_threads (
151
- store_id TEXT NOT NULL,
152
- forum_id TEXT NOT NULL,
153
- topic_id TEXT NOT NULL,
154
- thread_id TEXT NOT NULL,
155
- artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
156
- PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
157
- );
158
- CREATE TABLE IF NOT EXISTS discourse_posts (
159
- store_id TEXT NOT NULL,
160
- sequence INTEGER NOT NULL,
161
- id TEXT NOT NULL,
162
- artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
163
- operation_id TEXT NOT NULL,
164
- command_json TEXT NOT NULL,
165
- forum_id TEXT NOT NULL,
166
- topic_id TEXT NOT NULL,
167
- thread_id TEXT NOT NULL,
168
- author_id TEXT NOT NULL,
169
- content_json TEXT NOT NULL,
170
- timestamp INTEGER NOT NULL,
171
- correlation_id TEXT,
172
- causation_id TEXT,
173
- reply_to_post_id TEXT,
174
- references_json TEXT NOT NULL,
175
- question_type TEXT CHECK (question_type IN ('question', 'answer')),
176
- response_id TEXT,
177
- target_id TEXT,
178
- PRIMARY KEY (store_id, id),
179
- UNIQUE (store_id, operation_id),
180
- UNIQUE (store_id, sequence)
181
- );
182
- CREATE INDEX IF NOT EXISTS discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
183
- CREATE TABLE IF NOT EXISTS discourse_events (
184
- store_id TEXT NOT NULL,
185
- sequence INTEGER NOT NULL,
186
- event_json TEXT NOT NULL,
187
- PRIMARY KEY (store_id, sequence)
188
- );
189
- CREATE TABLE IF NOT EXISTS discourse_cursors (
190
- store_id TEXT NOT NULL,
191
- consumer_id TEXT NOT NULL,
192
- sequence INTEGER NOT NULL,
193
- PRIMARY KEY (store_id, consumer_id)
194
- );
195
- CREATE TABLE IF NOT EXISTS discourse_projection_cursors (
196
- store_id TEXT NOT NULL,
197
- projection_id TEXT NOT NULL,
198
- sequence INTEGER NOT NULL,
199
- PRIMARY KEY (store_id, projection_id)
200
- );
201
- CREATE TRIGGER IF NOT EXISTS discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
202
- WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
203
- BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
204
- CREATE TRIGGER IF NOT EXISTS discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
205
- WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
206
- BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
207
- CREATE TRIGGER IF NOT EXISTS discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
208
- WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
209
- OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
210
- BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
211
150
  CREATE TABLE IF NOT EXISTS artifact_events (
212
151
  id INTEGER PRIMARY KEY AUTOINCREMENT,
213
152
  artifact_id TEXT NOT NULL REFERENCES artifacts(id),
@@ -303,8 +242,6 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
303
242
  INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
304
243
  INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
305
244
  INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
306
- INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
307
- INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
308
245
  CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
309
246
  `;
310
247
 
@@ -349,6 +286,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
349
286
  { version: 1, name: "baseline", checksum: "af81e9f51d915ba538af3f468dc044bda5e2c5a5f5037e9c7c01540f87288763" },
350
287
  { version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
351
288
  { version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
289
+ { version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
352
290
  ];
353
291
 
354
292
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -675,6 +613,30 @@ export function migrateDb(db: Db): MigrationResult {
675
613
  `);
676
614
  applied.push("log-domain");
677
615
  }
616
+ if (schemaVersion(db) === 11) {
617
+ // Removes Discourse's Papyrus-embedded storage entirely: confirmed zero rows in every
618
+ // discourse_* table and zero Docs carrying the reserved context-thread/context-message
619
+ // subtypes in the real production database before this was written -- Discourse's real
620
+ // home is now the standalone @danypops/discourse package plus host adapters, and
621
+ // Papyrus's own copy never had a single real caller since it was built. IF EXISTS
622
+ // throughout: a database that never actually reached the v5->v6 discourse-context-mesh
623
+ // step in the first place (e.g. a test fixture that starts partway through the chain)
624
+ // must not fail here just because there was nothing to remove.
625
+ db.exec(`
626
+ DROP TRIGGER IF EXISTS discourse_artifact_type_immutable;
627
+ DROP TRIGGER IF EXISTS discourse_posts_artifact_type;
628
+ DROP TRIGGER IF EXISTS discourse_threads_artifact_type;
629
+ DROP INDEX IF EXISTS discourse_posts_thread_idx;
630
+ DROP TABLE IF EXISTS discourse_projection_cursors;
631
+ DROP TABLE IF EXISTS discourse_cursors;
632
+ DROP TABLE IF EXISTS discourse_events;
633
+ DROP TABLE IF EXISTS discourse_posts;
634
+ DROP TABLE IF EXISTS discourse_threads;
635
+ DELETE FROM relation_names WHERE name IN ('reply_to', 'discusses');
636
+ PRAGMA user_version = 12;
637
+ `);
638
+ applied.push("remove-discourse");
639
+ }
678
640
  if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
679
641
  });
680
642
  if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
@@ -23,9 +23,7 @@
23
23
  * replaces old ids wherever they appear inside a known set of free-text/JSON columns (title,
24
24
  * body, extra, and the two Task-event text fields) — this is how a prose cross-reference like
25
25
  * "see task some-old-id for the parent epic" keeps pointing at the right artifact after its id
26
- * changes. Discourse post JSON payloads (content_json/command_json/references_json) are
27
- * explicitly NOT scanned — that is Discourse-internal structure this tool does not have enough
28
- * context on yet, tracked as a known limitation rather than guessed at.
26
+ * changes.
29
27
  */
30
28
  import type { Db } from "./db.ts";
31
29
  import { inTransaction } from "./db.ts";
@@ -56,8 +54,6 @@ const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
56
54
  { table: "task_events", column: "task_id" },
57
55
  { table: "task_scopes", column: "task_id" },
58
56
  { table: "task_views", column: "root_task_id" },
59
- { table: "discourse_threads", column: "artifact_id" },
60
- { table: "discourse_posts", column: "artifact_id" },
61
57
  { table: "artifact_events", column: "artifact_id" },
62
58
  { table: "artifact_events", column: "related_id" },
63
59
  ];
package/src/service.ts CHANGED
@@ -3,14 +3,12 @@ import { VERSION } from "./version.ts";
3
3
  import { migrateDb, openDb, schemaVersion } from "./db.ts";
4
4
  import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
5
5
  import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
- import { SQLiteDiscourseStore } from "./adapters/sqlite-discourse-store.ts";
7
6
  import { SQLiteArtifactScopeStore } from "./adapters/sqlite-artifact-scope-store.ts";
8
7
  import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-store.ts";
9
8
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
10
9
  import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
11
10
  import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
12
11
  import type { CreateArtifactInput } from "./domain/artifact.ts";
13
- import { DISCOURSE_RELATIONS, isDiscourseSubtype } from "./domain/discourse-store.ts";
14
12
  import { AuthorityRegistry, AuthorizedArtifactWriter, type AuthorityClaim } from "./authority-registry.ts";
15
13
  import type { TaskEventContext } from "./domain/task-event.ts";
16
14
  import type { TaskViewMode } from "./domain/task-scope.ts";
@@ -41,12 +39,13 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
41
39
  * no domain owns creation/linking/traversal for every kind, the same way system.migrate
42
40
  * has no owning module) and two permanent composition-root exceptions (rules.injectable
43
41
  * needs tasks.active(); skills.instantiate branches into tasks.create()) -- see
44
- * src/modules/rules.ts and src/modules/skills.ts's module comments. discourse.store's
45
- * eventual home depends on the still-open Discourse projection-target decision, not on
46
- * module extraction.
42
+ * src/modules/rules.ts and src/modules/skills.ts's module comments. Discourse's own
43
+ * Papyrus-embedded storage (discourse.store) was removed entirely -- zero real callers
44
+ * were ever confirmed against it; Discourse's real home is the standalone
45
+ * @danypops/discourse package plus host adapters.
47
46
  */
48
47
  const COMPOSITION_ROOT_OPERATION_NAMES = [
49
- "system.migrate", "discourse.store", "artifact.create", "artifact.query", "artifact.show",
48
+ "system.migrate", "artifact.create", "artifact.query", "artifact.show",
50
49
  "graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
51
50
  "rules.injectable", "skills.instantiate",
52
51
  ] as const;
@@ -129,13 +128,6 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
129
128
  */
130
129
  const GENERIC_CALLER = "generic";
131
130
 
132
- const discourseAuthorityClaim: AuthorityClaim = {
133
- owner: "discourse",
134
- matchesArtifact: (_kind, subtype) => isDiscourseSubtype(subtype),
135
- matchesRelation: (relation) => DISCOURSE_RELATIONS.has(relation),
136
- denyMessage: (action) => action === "link" ? "forum-owned Context Mesh links require discourse.store" : "forum-owned Context Mesh Docs require discourse.store",
137
- };
138
-
139
131
  const notesAuthorityClaim: AuthorityClaim = {
140
132
  owner: "notes",
141
133
  matchesArtifact: (kind, subtype) => kind === "doc" && subtype === NOTE_SUBTYPE,
@@ -158,7 +150,7 @@ const tasksAuthorityClaim: AuthorityClaim = {
158
150
 
159
151
  function createAuthorityRegistry(): AuthorityRegistry {
160
152
  const authority = new AuthorityRegistry();
161
- authority.claimAll([discourseAuthorityClaim, notesAuthorityClaim, tasksAuthorityClaim]);
153
+ authority.claimAll([notesAuthorityClaim, tasksAuthorityClaim]);
162
154
  return authority;
163
155
  }
164
156
 
@@ -182,7 +174,6 @@ function handlers(
182
174
  gates: GateRunner,
183
175
  tasks: Tasks,
184
176
  notes: Notes,
185
- discourse: SQLiteDiscourseStore,
186
177
  events: TaskEventStore,
187
178
  scopes: TaskScopeStore,
188
179
  migrate: () => unknown,
@@ -219,7 +210,6 @@ function handlers(
219
210
  });
220
211
  return {
221
212
  "system.migrate": () => migrate(),
222
- "discourse.store": (input) => discourse.execute(input),
223
213
  "artifact.create": (input) => {
224
214
  const normalized = normalizeCreateInput(input);
225
215
  authority.requireArtifactAllowed(normalized.kind, normalized.subtype ?? templateSubtype(artifacts, normalized.templateId), "create", GENERIC_CALLER);
@@ -357,10 +347,9 @@ function handlers(
357
347
  "skills.instantiate": (input) => {
358
348
  const templateId = string(input, "template_id");
359
349
  const template = artifacts.get(templateId);
360
- // Deliberately pass an unresolved kind so only the discourse claim (kind-agnostic) can match here —
361
- // the historical requireDiscourseStoreForSubtype never checked notes at this call site; that check
362
- // already happens inside instantiateTemplate's own rejectsNoteTemplate for the non-task branch below.
363
- authority.requireArtifactAllowed(undefined, templateSubtype(artifacts, templateId), "create", GENERIC_CALLER);
350
+ // Note ownership for a non-task template target is enforced inside instantiateTemplate's
351
+ // own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
352
+ // an unresolved (pre-template-resolution) kind, so there is no check to perform here.
364
353
  if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input), authority, eventContext(input));
365
354
  return tasks.create({
366
355
  title: optionalString(input, "title") as string,
@@ -389,7 +378,6 @@ export function createPapyrusService(path: string): PapyrusService {
389
378
  const scopes = new SQLiteTaskScopeStore(db);
390
379
  const tasks = new Tasks(artifacts, gates, focus, events, scopes);
391
380
  const notes = new Notes(artifacts);
392
- const discourse = new SQLiteDiscourseStore(db, artifacts);
393
381
  const projections = new SQLiteGraphProjectionStore(db);
394
382
  const artifactScopes = new SQLiteArtifactScopeStore(db);
395
383
  const logs = new Logs(new SQLiteLogStore(db));
@@ -402,7 +390,7 @@ export function createPapyrusService(path: string): PapyrusService {
402
390
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
403
391
  moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
404
392
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
405
- const registry = handlers(artifacts, gates, tasks, notes, discourse, events, scopes, () => migrateDb(db), moduleRegistry, authority);
393
+ const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
406
394
  const state = (): SchemaState => {
407
395
  const current = schemaVersion(db);
408
396
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
@@ -1,325 +0,0 @@
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
- }
@@ -1,142 +0,0 @@
1
- import {
2
- DISCOURSE_CONTENT_MAX_BYTES,
3
- DISCOURSE_EVENT_RETENTION_DEFAULT,
4
- DISCOURSE_EVENT_RETENTION_MAX,
5
- DISCOURSE_QUERY_MAX_LIMIT,
6
- } from "../constants.ts";
7
-
8
- /** Papyrus-owned Doc subtypes reserved for the Discourse persistence adapter. */
9
- export const DISCOURSE_THREAD_SUBTYPE = "context-thread";
10
- export const DISCOURSE_MESSAGE_SUBTYPE = "context-message";
11
- export const DISCOURSE_RELATIONS = new Set(["reply_to", "discusses"]);
12
-
13
- export type JsonPrimitive = string | number | boolean | null;
14
- export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
15
- export interface ArtifactReference { kind: string; id: string }
16
- export interface ThreadAddress { forumId: string; topicId: string; threadId: string }
17
- export interface AppendPostCommand extends ThreadAddress {
18
- schemaVersion: "discourse.command.v1";
19
- operationId: string;
20
- authorId: string;
21
- content: JsonValue;
22
- correlationId?: string;
23
- causationId?: string;
24
- replyToPostId?: string;
25
- references?: ArtifactReference[];
26
- }
27
- export interface Post extends ThreadAddress {
28
- id: string;
29
- authorId: string;
30
- content: JsonValue;
31
- timestamp: number;
32
- sequence: number;
33
- operationId: string;
34
- correlationId?: string;
35
- causationId?: string;
36
- replyToPostId?: string;
37
- references: ArtifactReference[];
38
- }
39
- export type DiscourseEventType = "post-added" | "thread-changed" | "question-opened" | "question-answered" | "subscription-resync-required";
40
- export interface DiscourseEvent extends ThreadAddress {
41
- schemaVersion: "discourse.event.v1";
42
- type: DiscourseEventType;
43
- sequence: number;
44
- timestamp: number;
45
- postId?: string;
46
- operationId?: string;
47
- correlationId?: string;
48
- causationId?: string;
49
- responseId?: string;
50
- retainedFromSequence?: number;
51
- }
52
- export interface Page<T> {
53
- items: T[];
54
- truncated: boolean;
55
- nextSequence?: number;
56
- completeness: "complete" | "truncated";
57
- }
58
- export interface TopicSummary { forumId: string; topicId: string; threadCount: number; postCount: number; lastActivity: number }
59
- export interface ThreadSummary extends ThreadAddress { postCount: number; participantIds: string[]; lastActivity: number }
60
- export interface OpenQuestion { responseId: string; post: Post }
61
- export interface ProjectionRecord { sequence: number; post: Post }
62
-
63
- export function isDiscourseSubtype(subtype: string | undefined): boolean {
64
- return subtype === DISCOURSE_THREAD_SUBTYPE || subtype === DISCOURSE_MESSAGE_SUBTYPE;
65
- }
66
-
67
- function record(value: unknown, name: string): Record<string, unknown> {
68
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
69
- return value as Record<string, unknown>;
70
- }
71
-
72
- export function requiredString(value: unknown, name: string): string {
73
- if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is required`);
74
- return value;
75
- }
76
-
77
- export function optionalString(value: unknown, name: string): string | undefined {
78
- if (value === undefined) return undefined;
79
- if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
80
- return value;
81
- }
82
-
83
- export function nonNegativeInteger(value: unknown, name: string): number {
84
- if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative safe integer`);
85
- return value as number;
86
- }
87
-
88
- export function queryLimit(value: unknown): number {
89
- const limit = nonNegativeInteger(value, "limit");
90
- if (limit < 1 || limit > DISCOURSE_QUERY_MAX_LIMIT) throw new Error(`limit must be between 1 and ${DISCOURSE_QUERY_MAX_LIMIT}`);
91
- return limit;
92
- }
93
-
94
- export function eventRetention(value: unknown): number {
95
- if (value === undefined) return DISCOURSE_EVENT_RETENTION_DEFAULT;
96
- const retention = nonNegativeInteger(value, "event_retention");
97
- if (retention < 1 || retention > DISCOURSE_EVENT_RETENTION_MAX) {
98
- throw new Error(`event_retention must be between 1 and ${DISCOURSE_EVENT_RETENTION_MAX}`);
99
- }
100
- return retention;
101
- }
102
-
103
- function jsonValue(value: unknown, name: string): JsonValue {
104
- const encoded = JSON.stringify(value);
105
- if (encoded === undefined) throw new Error(`${name} must be JSON-serializable`);
106
- if (new TextEncoder().encode(encoded).byteLength > DISCOURSE_CONTENT_MAX_BYTES) {
107
- throw new Error(`${name} cannot exceed ${DISCOURSE_CONTENT_MAX_BYTES} bytes`);
108
- }
109
- return JSON.parse(encoded) as JsonValue;
110
- }
111
-
112
- export function appendCommand(value: unknown): AppendPostCommand {
113
- const input = record(value, "command");
114
- if (input["schemaVersion"] !== "discourse.command.v1") throw new Error("unsupported Discourse command schema");
115
- const referencesValue = input["references"] ?? [];
116
- if (!Array.isArray(referencesValue)) throw new Error("references must be an array");
117
- const references = referencesValue.map((entry, index) => {
118
- const reference = record(entry, `references[${index}]`);
119
- return { kind: requiredString(reference["kind"], `references[${index}].kind`), id: requiredString(reference["id"], `references[${index}].id`) };
120
- });
121
- return {
122
- schemaVersion: "discourse.command.v1",
123
- operationId: requiredString(input["operationId"], "operationId"),
124
- forumId: requiredString(input["forumId"], "forumId"),
125
- topicId: requiredString(input["topicId"], "topicId"),
126
- threadId: requiredString(input["threadId"], "threadId"),
127
- authorId: requiredString(input["authorId"], "authorId"),
128
- content: jsonValue(input["content"], "content"),
129
- ...(optionalString(input["correlationId"], "correlationId") ? { correlationId: input["correlationId"] as string } : {}),
130
- ...(optionalString(input["causationId"], "causationId") ? { causationId: input["causationId"] as string } : {}),
131
- ...(optionalString(input["replyToPostId"], "replyToPostId") ? { replyToPostId: input["replyToPostId"] as string } : {}),
132
- references,
133
- };
134
- }
135
-
136
- export function threadAddress(value: Record<string, unknown>): ThreadAddress {
137
- return {
138
- forumId: requiredString(value["forumId"], "forumId"),
139
- topicId: requiredString(value["topicId"], "topicId"),
140
- threadId: requiredString(value["threadId"], "threadId"),
141
- };
142
- }