@danypops/papyrus 0.13.6 → 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/extension/src/index.ts +36 -2
- package/package.json +1 -1
- package/src/adapters/sqlite-log-store.ts +88 -0
- package/src/authority-registry.ts +1 -1
- package/src/cli.ts +72 -36
- package/src/constants.ts +2 -7
- package/src/db.ts +86 -64
- package/src/domain/log-entry.ts +120 -0
- package/src/id-migration.ts +1 -5
- package/src/log-service.ts +58 -0
- package/src/modules/logs.ts +75 -0
- package/src/ports/log-store.ts +17 -0
- package/src/service.ts +18 -22
- package/src/adapters/sqlite-discourse-store.ts +0 -325
- package/src/domain/discourse-store.ts +0 -142
package/src/id-migration.ts
CHANGED
|
@@ -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.
|
|
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
|
];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
boundMessage,
|
|
4
|
+
LOG_QUERY_MAX_ENTRIES,
|
|
5
|
+
LOG_RETENTION_MAX_ENTRIES_PER_SOURCE,
|
|
6
|
+
meetsLevel,
|
|
7
|
+
validateAppendLogEntryCommand,
|
|
8
|
+
type AppendLogEntryCommand,
|
|
9
|
+
type AppendLogEntryResult,
|
|
10
|
+
type LogEntryPage,
|
|
11
|
+
type LogQuery,
|
|
12
|
+
} from "./domain/log-entry.ts";
|
|
13
|
+
import type { LogStore } from "./ports/log-store.ts";
|
|
14
|
+
|
|
15
|
+
export class Logs {
|
|
16
|
+
constructor(private readonly store: LogStore) {}
|
|
17
|
+
|
|
18
|
+
append(command: AppendLogEntryCommand): AppendLogEntryResult {
|
|
19
|
+
validateAppendLogEntryCommand(command);
|
|
20
|
+
this.store.ensureSource(command.sourceId, command.sourceLabel ?? command.sourceId, command.projectRoot ?? null);
|
|
21
|
+
const existing = this.store.findEntryByOperationId(command.sourceId, command.operationId);
|
|
22
|
+
if (existing) return { entry: existing, replayed: true };
|
|
23
|
+
|
|
24
|
+
const { message, truncated } = boundMessage(command.message);
|
|
25
|
+
const entry = {
|
|
26
|
+
id: randomUUID(),
|
|
27
|
+
sourceId: command.sourceId,
|
|
28
|
+
occurredAt: command.occurredAt ?? new Date().toISOString(),
|
|
29
|
+
level: command.level,
|
|
30
|
+
message,
|
|
31
|
+
truncated,
|
|
32
|
+
fields: command.fields ?? {},
|
|
33
|
+
operationId: command.operationId,
|
|
34
|
+
sessionId: command.sessionId,
|
|
35
|
+
};
|
|
36
|
+
this.store.insertEntry(entry);
|
|
37
|
+
this.store.trimSource(command.sourceId, LOG_RETENTION_MAX_ENTRIES_PER_SOURCE);
|
|
38
|
+
return { entry, replayed: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `since` given: this is a live-tail/polling cursor -- return the OLDEST entries in the
|
|
43
|
+
* window so a caller can advance its cursor without skipping any, at the cost of not yet
|
|
44
|
+
* seeing the very latest (call again with a later `since` to keep catching up).
|
|
45
|
+
* `since` omitted: this is a post-mortem browse -- return the MOST RECENT entries,
|
|
46
|
+
* matching `tail -n`'s familiar behavior.
|
|
47
|
+
*/
|
|
48
|
+
query(query: LogQuery): LogEntryPage {
|
|
49
|
+
const limit = Math.min(query.limit ?? LOG_QUERY_MAX_ENTRIES, LOG_QUERY_MAX_ENTRIES);
|
|
50
|
+
const matching = this.store.entriesForSource(query.sourceId)
|
|
51
|
+
.filter((entry) => query.since === undefined || entry.occurredAt > query.since)
|
|
52
|
+
.filter((entry) => query.level === undefined || meetsLevel(entry.level, query.level));
|
|
53
|
+
|
|
54
|
+
if (matching.length <= limit) return { entries: matching, truncated: false };
|
|
55
|
+
const windowed = query.since !== undefined ? matching.slice(0, limit) : matching.slice(matching.length - limit);
|
|
56
|
+
return { entries: windowed, truncated: true };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/logs.ts — the `log` domain as a registered Papyrus-native module.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately self-contained: does not import artifact/task/rule/skill infrastructure --
|
|
5
|
+
* logs never touch the Artifact graph directly (see src/domain/log-entry.ts's own module
|
|
6
|
+
* comment on why `log` is not an Artifact kind).
|
|
7
|
+
*/
|
|
8
|
+
import type { JsonValue, LogLevel } from "../domain/log-entry.ts";
|
|
9
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
10
|
+
import type { Logs } from "../log-service.ts";
|
|
11
|
+
|
|
12
|
+
const MODULE_ID = "logs";
|
|
13
|
+
|
|
14
|
+
type OperationInput = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
function string(input: OperationInput, key: string): string {
|
|
17
|
+
const value = input[key];
|
|
18
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
23
|
+
const value = input[key];
|
|
24
|
+
if (value === undefined) return undefined;
|
|
25
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
30
|
+
const value = input[key];
|
|
31
|
+
if (value === undefined) return undefined;
|
|
32
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isJsonValue(value: unknown): value is JsonValue {
|
|
37
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
|
38
|
+
|| Array.isArray(value) || (typeof value === "object");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function optionalFields(input: OperationInput, key: string): JsonValue | undefined {
|
|
42
|
+
const value = input[key];
|
|
43
|
+
if (value === undefined) return undefined;
|
|
44
|
+
if (!isJsonValue(value)) throw new Error(`${key} must be JSON-serializable`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
49
|
+
export const LOGS_OPERATION_NAMES = ["logs.append", "logs.query"] as const;
|
|
50
|
+
|
|
51
|
+
/** Registers every logs.* operation against one Logs instance. */
|
|
52
|
+
export function logsOperations(logs: Logs): OperationDefinition[] {
|
|
53
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
54
|
+
name, moduleId: MODULE_ID, execute,
|
|
55
|
+
});
|
|
56
|
+
return [
|
|
57
|
+
define("logs.append", (input: OperationInput) => logs.append({
|
|
58
|
+
sourceId: string(input, "source_id"),
|
|
59
|
+
sourceLabel: optionalString(input, "source_label"),
|
|
60
|
+
projectRoot: optionalString(input, "project_root") ?? null,
|
|
61
|
+
level: string(input, "level") as LogLevel,
|
|
62
|
+
message: string(input, "message"),
|
|
63
|
+
fields: optionalFields(input, "fields"),
|
|
64
|
+
operationId: string(input, "operation_id"),
|
|
65
|
+
sessionId: optionalString(input, "session_id"),
|
|
66
|
+
occurredAt: optionalString(input, "occurred_at"),
|
|
67
|
+
})),
|
|
68
|
+
define("logs.query", (input: OperationInput) => logs.query({
|
|
69
|
+
sourceId: string(input, "source_id"),
|
|
70
|
+
since: optionalString(input, "since"),
|
|
71
|
+
level: optionalString(input, "level") as LogLevel | undefined,
|
|
72
|
+
limit: optionalNumber(input, "limit"),
|
|
73
|
+
})),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LogEntry, LogSource } from "../domain/log-entry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistence port for the `log` domain. Deliberately minimal, matching
|
|
5
|
+
* ConversationJournalStore's own split: this is dumb storage (idempotency-key lookup,
|
|
6
|
+
* insert, bounded-at-the-service-layer reads) plus one operation the store must own because
|
|
7
|
+
* only it knows real row counts -- retention trimming.
|
|
8
|
+
*/
|
|
9
|
+
export interface LogStore {
|
|
10
|
+
ensureSource(sourceId: string, label: string, projectRoot: string | null): LogSource;
|
|
11
|
+
findEntryByOperationId(sourceId: string, operationId: string): LogEntry | undefined;
|
|
12
|
+
insertEntry(entry: LogEntry): void;
|
|
13
|
+
/** All entries for one source, chronological (oldest first), unbounded at the store layer -- the service applies query bounds/filters. */
|
|
14
|
+
entriesForSource(sourceId: string): readonly LogEntry[];
|
|
15
|
+
/** Deletes the oldest entries for a source beyond `maxEntries`, returning how many were removed -- retention enforcement, not a general delete capability. */
|
|
16
|
+
trimSource(sourceId: string, maxEntries: number): number;
|
|
17
|
+
}
|
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";
|
|
@@ -24,9 +22,12 @@ import {
|
|
|
24
22
|
listInjectableRules,
|
|
25
23
|
} from "./domain-services.ts";
|
|
26
24
|
import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
|
|
25
|
+
import { Logs } from "./log-service.ts";
|
|
26
|
+
import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
|
|
27
27
|
import { OperationRegistry } from "./module-registry.ts";
|
|
28
28
|
import { docsOperations, DOCS_OPERATION_NAMES } from "./modules/docs.ts";
|
|
29
29
|
import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./modules/graph-projection.ts";
|
|
30
|
+
import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
|
|
30
31
|
import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
|
|
31
32
|
import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
|
|
32
33
|
import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
@@ -38,12 +39,13 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
|
38
39
|
* no domain owns creation/linking/traversal for every kind, the same way system.migrate
|
|
39
40
|
* has no owning module) and two permanent composition-root exceptions (rules.injectable
|
|
40
41
|
* needs tasks.active(); skills.instantiate branches into tasks.create()) -- see
|
|
41
|
-
* src/modules/rules.ts and src/modules/skills.ts's module comments.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
44
46
|
*/
|
|
45
47
|
const COMPOSITION_ROOT_OPERATION_NAMES = [
|
|
46
|
-
"system.migrate", "
|
|
48
|
+
"system.migrate", "artifact.create", "artifact.query", "artifact.show",
|
|
47
49
|
"graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
|
|
48
50
|
"rules.injectable", "skills.instantiate",
|
|
49
51
|
] as const;
|
|
@@ -65,6 +67,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
65
67
|
...RULES_OPERATION_NAMES,
|
|
66
68
|
...SKILLS_OPERATION_NAMES,
|
|
67
69
|
...GRAPH_PROJECTION_OPERATION_NAMES,
|
|
70
|
+
...LOGS_OPERATION_NAMES,
|
|
68
71
|
] as const;
|
|
69
72
|
|
|
70
73
|
export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
@@ -125,13 +128,6 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
|
|
|
125
128
|
*/
|
|
126
129
|
const GENERIC_CALLER = "generic";
|
|
127
130
|
|
|
128
|
-
const discourseAuthorityClaim: AuthorityClaim = {
|
|
129
|
-
owner: "discourse",
|
|
130
|
-
matchesArtifact: (_kind, subtype) => isDiscourseSubtype(subtype),
|
|
131
|
-
matchesRelation: (relation) => DISCOURSE_RELATIONS.has(relation),
|
|
132
|
-
denyMessage: (action) => action === "link" ? "forum-owned Context Mesh links require discourse.store" : "forum-owned Context Mesh Docs require discourse.store",
|
|
133
|
-
};
|
|
134
|
-
|
|
135
131
|
const notesAuthorityClaim: AuthorityClaim = {
|
|
136
132
|
owner: "notes",
|
|
137
133
|
matchesArtifact: (kind, subtype) => kind === "doc" && subtype === NOTE_SUBTYPE,
|
|
@@ -154,7 +150,7 @@ const tasksAuthorityClaim: AuthorityClaim = {
|
|
|
154
150
|
|
|
155
151
|
function createAuthorityRegistry(): AuthorityRegistry {
|
|
156
152
|
const authority = new AuthorityRegistry();
|
|
157
|
-
authority.claimAll([
|
|
153
|
+
authority.claimAll([notesAuthorityClaim, tasksAuthorityClaim]);
|
|
158
154
|
return authority;
|
|
159
155
|
}
|
|
160
156
|
|
|
@@ -178,7 +174,6 @@ function handlers(
|
|
|
178
174
|
gates: GateRunner,
|
|
179
175
|
tasks: Tasks,
|
|
180
176
|
notes: Notes,
|
|
181
|
-
discourse: SQLiteDiscourseStore,
|
|
182
177
|
events: TaskEventStore,
|
|
183
178
|
scopes: TaskScopeStore,
|
|
184
179
|
migrate: () => unknown,
|
|
@@ -215,7 +210,6 @@ function handlers(
|
|
|
215
210
|
});
|
|
216
211
|
return {
|
|
217
212
|
"system.migrate": () => migrate(),
|
|
218
|
-
"discourse.store": (input) => discourse.execute(input),
|
|
219
213
|
"artifact.create": (input) => {
|
|
220
214
|
const normalized = normalizeCreateInput(input);
|
|
221
215
|
authority.requireArtifactAllowed(normalized.kind, normalized.subtype ?? templateSubtype(artifacts, normalized.templateId), "create", GENERIC_CALLER);
|
|
@@ -353,10 +347,9 @@ function handlers(
|
|
|
353
347
|
"skills.instantiate": (input) => {
|
|
354
348
|
const templateId = string(input, "template_id");
|
|
355
349
|
const template = artifacts.get(templateId);
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
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.
|
|
360
353
|
if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input), authority, eventContext(input));
|
|
361
354
|
return tasks.create({
|
|
362
355
|
title: optionalString(input, "title") as string,
|
|
@@ -371,6 +364,8 @@ function handlers(
|
|
|
371
364
|
},
|
|
372
365
|
"graph_projection.apply": forwardToModule("graph_projection.apply"),
|
|
373
366
|
"graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
|
|
367
|
+
"logs.append": forwardToModule("logs.append"),
|
|
368
|
+
"logs.query": forwardToModule("logs.query"),
|
|
374
369
|
};
|
|
375
370
|
}
|
|
376
371
|
|
|
@@ -383,18 +378,19 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
383
378
|
const scopes = new SQLiteTaskScopeStore(db);
|
|
384
379
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes);
|
|
385
380
|
const notes = new Notes(artifacts);
|
|
386
|
-
const discourse = new SQLiteDiscourseStore(db, artifacts);
|
|
387
381
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
388
382
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
383
|
+
const logs = new Logs(new SQLiteLogStore(db));
|
|
389
384
|
const authority = createAuthorityRegistry();
|
|
390
385
|
const moduleRegistry = new OperationRegistry();
|
|
391
386
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
387
|
+
moduleRegistry.registerAll(logsOperations(logs));
|
|
392
388
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts));
|
|
393
389
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
394
390
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
395
391
|
moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
|
|
396
392
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|
|
397
|
-
const registry = handlers(artifacts, gates, tasks, notes,
|
|
393
|
+
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
|
|
398
394
|
const state = (): SchemaState => {
|
|
399
395
|
const current = schemaVersion(db);
|
|
400
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
|
-
}
|