@gmickel/gno 1.20.0 → 1.21.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/README.md +19 -4
- package/assets/skill/SKILL.md +46 -15
- package/package.json +1 -1
- package/spec/cli.md +100 -0
- package/spec/db/schema.sql +170 -0
- package/spec/mcp.md +22 -0
- package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
- package/spec/output-schemas/changes.schema.json +280 -0
- package/spec/output-schemas/document-diff.schema.json +185 -0
- package/spec/output-schemas/impact.schema.json +122 -0
- package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
- package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
- package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
- package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
- package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
- package/src/cli/commands/changes.ts +160 -0
- package/src/cli/commands/context-saved.ts +189 -0
- package/src/cli/options.ts +8 -0
- package/src/cli/program.ts +195 -0
- package/src/core/capsule-registry.ts +279 -0
- package/src/core/capsule-reverification-scheduler.ts +218 -0
- package/src/core/capsule-reverification.ts +289 -0
- package/src/core/change-diff.ts +182 -0
- package/src/core/change-journal.ts +228 -0
- package/src/core/knowledge-delta.ts +395 -0
- package/src/core/knowledge-impact.ts +202 -0
- package/src/ingestion/sync.ts +214 -165
- package/src/mcp/tools/changes.ts +80 -0
- package/src/mcp/tools/index.ts +29 -0
- package/src/sdk/client.ts +42 -0
- package/src/sdk/index.ts +7 -0
- package/src/sdk/types.ts +22 -0
- package/src/serve/doc-events.ts +12 -1
- package/src/serve/resident-runtime.ts +22 -0
- package/src/serve/routes/api.ts +13 -0
- package/src/serve/routes/changes.ts +102 -0
- package/src/serve/server.ts +34 -0
- package/src/serve/watch-service.ts +9 -0
- package/src/store/index.ts +21 -0
- package/src/store/migrations/015-document-change-journal.ts +85 -0
- package/src/store/migrations/016-saved-capsules.ts +131 -0
- package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
- package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
- package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
- package/src/store/migrations/index.ts +10 -0
- package/src/store/sqlite/adapter.ts +291 -7
- package/src/store/sqlite/capsule-registry-store.ts +534 -0
- package/src/store/sqlite/change-journal-store.ts +473 -0
- package/src/store/types.ts +262 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, storage-agnostic contracts for the document change journal.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
DocumentChangeDateDelta,
|
|
7
|
+
DocumentChangeSet,
|
|
8
|
+
DocumentChangeStructureDelta,
|
|
9
|
+
DocumentChangeRetentionPolicy,
|
|
10
|
+
} from "../store/types";
|
|
11
|
+
|
|
12
|
+
const CURSOR_PREFIX = "gno-change-v1.";
|
|
13
|
+
const MAX_DELTA_ITEMS_PER_SIDE = 16;
|
|
14
|
+
const MAX_DELTA_VALUE_JSON_BYTES = 256;
|
|
15
|
+
export const MAX_DOCUMENT_CHANGE_DELTA_JSON_BYTES = 16 * 1024;
|
|
16
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_DOCUMENT_CHANGE_RETENTION: DocumentChangeRetentionPolicy =
|
|
19
|
+
{
|
|
20
|
+
maxAgeDays: 30,
|
|
21
|
+
maxEntries: 10_000,
|
|
22
|
+
maxBytes: 16 * 1024 * 1024,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const EMPTY_DOCUMENT_CHANGE_SET: DocumentChangeSet = {
|
|
26
|
+
added: [],
|
|
27
|
+
removed: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const EMPTY_DOCUMENT_CHANGE_DATE_DELTA: DocumentChangeDateDelta = {
|
|
31
|
+
added: [],
|
|
32
|
+
removed: [],
|
|
33
|
+
changed: [],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const EMPTY_DOCUMENT_CHANGE_STRUCTURE_DELTA: DocumentChangeStructureDelta =
|
|
37
|
+
{
|
|
38
|
+
headings: EMPTY_DOCUMENT_CHANGE_SET,
|
|
39
|
+
links: EMPTY_DOCUMENT_CHANGE_SET,
|
|
40
|
+
typedEdges: EMPTY_DOCUMENT_CHANGE_SET,
|
|
41
|
+
dates: EMPTY_DOCUMENT_CHANGE_DATE_DELTA,
|
|
42
|
+
truncated: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const normalizeValues = (
|
|
46
|
+
values: readonly string[] | undefined
|
|
47
|
+
): { values: string[]; truncated: boolean } => {
|
|
48
|
+
const unique = [
|
|
49
|
+
...new Set(
|
|
50
|
+
(values ?? [])
|
|
51
|
+
.map((value) => value.trim())
|
|
52
|
+
.filter((value) => value.length > 0)
|
|
53
|
+
),
|
|
54
|
+
].sort();
|
|
55
|
+
const selected: string[] = [];
|
|
56
|
+
const selectedValues = new Set<string>();
|
|
57
|
+
let truncated = false;
|
|
58
|
+
for (const value of unique) {
|
|
59
|
+
let normalized = "";
|
|
60
|
+
let jsonByteLength = 0;
|
|
61
|
+
for (const character of value) {
|
|
62
|
+
const escaped = JSON.stringify(character).slice(1, -1);
|
|
63
|
+
const characterBytes = UTF8_ENCODER.encode(escaped).byteLength;
|
|
64
|
+
if (jsonByteLength + characterBytes > MAX_DELTA_VALUE_JSON_BYTES) {
|
|
65
|
+
truncated = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
normalized += character;
|
|
69
|
+
jsonByteLength += characterBytes;
|
|
70
|
+
}
|
|
71
|
+
if (normalized !== value) {
|
|
72
|
+
truncated = true;
|
|
73
|
+
}
|
|
74
|
+
if (selectedValues.has(normalized)) {
|
|
75
|
+
truncated = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (selected.length === MAX_DELTA_ITEMS_PER_SIDE) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
selected.push(normalized);
|
|
83
|
+
selectedValues.add(normalized);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
values: selected,
|
|
87
|
+
truncated,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export interface SerializedDocumentChangeStructureDelta {
|
|
92
|
+
delta: DocumentChangeStructureDelta;
|
|
93
|
+
headingDeltaJson: string;
|
|
94
|
+
linkDeltaJson: string;
|
|
95
|
+
typedEdgeDeltaJson: string;
|
|
96
|
+
dateDeltaJson: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const normalizeSet = (
|
|
100
|
+
value: Partial<DocumentChangeSet> | undefined
|
|
101
|
+
): { value: DocumentChangeSet; truncated: boolean } => {
|
|
102
|
+
const added = normalizeValues(value?.added);
|
|
103
|
+
const removed = normalizeValues(value?.removed);
|
|
104
|
+
return {
|
|
105
|
+
value: { added: added.values, removed: removed.values },
|
|
106
|
+
truncated: added.truncated || removed.truncated,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const normalizeDates = (
|
|
111
|
+
value: Partial<DocumentChangeDateDelta> | undefined
|
|
112
|
+
): { value: DocumentChangeDateDelta; truncated: boolean } => {
|
|
113
|
+
const added = normalizeValues(value?.added);
|
|
114
|
+
const removed = normalizeValues(value?.removed);
|
|
115
|
+
const changed = normalizeValues(value?.changed);
|
|
116
|
+
return {
|
|
117
|
+
value: {
|
|
118
|
+
added: added.values,
|
|
119
|
+
removed: removed.values,
|
|
120
|
+
changed: changed.values,
|
|
121
|
+
},
|
|
122
|
+
truncated: added.truncated || removed.truncated || changed.truncated,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const normalizeDocumentChangeStructureDelta = (
|
|
127
|
+
value?: Partial<DocumentChangeStructureDelta>
|
|
128
|
+
): DocumentChangeStructureDelta => {
|
|
129
|
+
const headings = normalizeSet(value?.headings);
|
|
130
|
+
const links = normalizeSet(value?.links);
|
|
131
|
+
const typedEdges = normalizeSet(value?.typedEdges);
|
|
132
|
+
const dates = normalizeDates(value?.dates);
|
|
133
|
+
return {
|
|
134
|
+
headings: headings.value,
|
|
135
|
+
links: links.value,
|
|
136
|
+
typedEdges: typedEdges.value,
|
|
137
|
+
dates: dates.value,
|
|
138
|
+
truncated:
|
|
139
|
+
(value?.truncated ?? false) ||
|
|
140
|
+
headings.truncated ||
|
|
141
|
+
links.truncated ||
|
|
142
|
+
typedEdges.truncated ||
|
|
143
|
+
dates.truncated,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Canonical storage projection for migration 015's UTF-8 byte constraints.
|
|
149
|
+
* Callers must use this instead of independently stringifying normalized deltas.
|
|
150
|
+
*/
|
|
151
|
+
export const serializeDocumentChangeStructureDelta = (
|
|
152
|
+
value?: Partial<DocumentChangeStructureDelta>
|
|
153
|
+
): SerializedDocumentChangeStructureDelta => {
|
|
154
|
+
const delta = normalizeDocumentChangeStructureDelta(value);
|
|
155
|
+
const serialized = {
|
|
156
|
+
headingDeltaJson: JSON.stringify(delta.headings),
|
|
157
|
+
linkDeltaJson: JSON.stringify(delta.links),
|
|
158
|
+
typedEdgeDeltaJson: JSON.stringify(delta.typedEdges),
|
|
159
|
+
dateDeltaJson: JSON.stringify(delta.dates),
|
|
160
|
+
};
|
|
161
|
+
for (const json of Object.values(serialized)) {
|
|
162
|
+
if (
|
|
163
|
+
UTF8_ENCODER.encode(json).byteLength >
|
|
164
|
+
MAX_DOCUMENT_CHANGE_DELTA_JSON_BYTES
|
|
165
|
+
) {
|
|
166
|
+
throw new RangeError(
|
|
167
|
+
"Normalized document change structure exceeds its UTF-8 storage limit"
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { delta, ...serialized };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const encodeDocumentChangeCursor = (sequence: number): string => {
|
|
175
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
176
|
+
throw new RangeError(
|
|
177
|
+
"Document change cursor sequence must be non-negative"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return `${CURSOR_PREFIX}${btoa(JSON.stringify({ sequence }))}`;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const decodeDocumentChangeCursor = (cursor: string): number => {
|
|
184
|
+
if (!cursor.startsWith(CURSOR_PREFIX)) {
|
|
185
|
+
throw new TypeError("Invalid document change cursor");
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const parsed: unknown = JSON.parse(
|
|
189
|
+
atob(cursor.slice(CURSOR_PREFIX.length))
|
|
190
|
+
);
|
|
191
|
+
if (
|
|
192
|
+
!parsed ||
|
|
193
|
+
typeof parsed !== "object" ||
|
|
194
|
+
!("sequence" in parsed) ||
|
|
195
|
+
!Number.isSafeInteger(parsed.sequence) ||
|
|
196
|
+
(parsed.sequence as number) < 0
|
|
197
|
+
) {
|
|
198
|
+
throw new TypeError("Invalid document change cursor");
|
|
199
|
+
}
|
|
200
|
+
return parsed.sequence as number;
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
if (cause instanceof TypeError) {
|
|
203
|
+
throw cause;
|
|
204
|
+
}
|
|
205
|
+
throw new TypeError("Invalid document change cursor", { cause });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const validateDocumentChangeRetentionPolicy = (
|
|
210
|
+
policy: DocumentChangeRetentionPolicy,
|
|
211
|
+
nowMs: number
|
|
212
|
+
): void => {
|
|
213
|
+
if (
|
|
214
|
+
!Number.isSafeInteger(nowMs) ||
|
|
215
|
+
nowMs < 0 ||
|
|
216
|
+
!Number.isSafeInteger(policy.maxAgeDays) ||
|
|
217
|
+
policy.maxAgeDays < 1 ||
|
|
218
|
+
policy.maxAgeDays > 3650 ||
|
|
219
|
+
!Number.isSafeInteger(policy.maxEntries) ||
|
|
220
|
+
policy.maxEntries < 1 ||
|
|
221
|
+
policy.maxEntries > 1_000_000 ||
|
|
222
|
+
!Number.isSafeInteger(policy.maxBytes) ||
|
|
223
|
+
policy.maxBytes < 1 ||
|
|
224
|
+
policy.maxBytes > 1024 * 1024 * 1024
|
|
225
|
+
) {
|
|
226
|
+
throw new RangeError("Invalid document change retention policy");
|
|
227
|
+
}
|
|
228
|
+
};
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared read services for retained knowledge changes, structural diffs, and
|
|
3
|
+
* bounded inbound dependency impact.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
DocumentChangeRow,
|
|
8
|
+
DocumentChangeStructureDelta,
|
|
9
|
+
DocumentRow,
|
|
10
|
+
StorePort,
|
|
11
|
+
} from "../store/types";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
decodeDocumentChangeCursor,
|
|
15
|
+
encodeDocumentChangeCursor,
|
|
16
|
+
} from "./change-journal";
|
|
17
|
+
import { resolveDocRef } from "./ref-parser";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CHANGE_LIMIT = 100;
|
|
20
|
+
const MAX_CHANGE_LIMIT = 1000;
|
|
21
|
+
const MAX_DIFF_SCAN_PAGES = 10;
|
|
22
|
+
const DIFF_SCAN_PAGE_SIZE = 1000;
|
|
23
|
+
|
|
24
|
+
export interface KnowledgeChangeSnapshot {
|
|
25
|
+
relPath: string;
|
|
26
|
+
docid: string;
|
|
27
|
+
uri: string;
|
|
28
|
+
sourceHash: string;
|
|
29
|
+
mirrorHash: string | null;
|
|
30
|
+
active: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KnowledgeChange {
|
|
34
|
+
id: string;
|
|
35
|
+
kind: DocumentChangeRow["kind"];
|
|
36
|
+
collection: string;
|
|
37
|
+
observedAt: string;
|
|
38
|
+
previous: KnowledgeChangeSnapshot | null;
|
|
39
|
+
current: KnowledgeChangeSnapshot | null;
|
|
40
|
+
structureDelta: DocumentChangeStructureDelta;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KnowledgeChangesResult {
|
|
44
|
+
schemaVersion: "1.0";
|
|
45
|
+
changes: KnowledgeChange[];
|
|
46
|
+
page: {
|
|
47
|
+
nextCursor: string | null;
|
|
48
|
+
earliestCursor: string;
|
|
49
|
+
latestCursor: string;
|
|
50
|
+
cursorExpired: boolean;
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
retentionTruncated: boolean;
|
|
53
|
+
};
|
|
54
|
+
warnings: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ListKnowledgeChangesInput {
|
|
58
|
+
since?: string;
|
|
59
|
+
collection?: string;
|
|
60
|
+
limit?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface KnowledgeDocument {
|
|
64
|
+
id: string;
|
|
65
|
+
uri: string;
|
|
66
|
+
title: string | null;
|
|
67
|
+
collection: string;
|
|
68
|
+
relPath: string;
|
|
69
|
+
active?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface KnowledgeDiffResult {
|
|
73
|
+
schemaVersion: "1.0";
|
|
74
|
+
status: "available" | "expired" | "unavailable";
|
|
75
|
+
document: KnowledgeDocument & { active: boolean };
|
|
76
|
+
change: KnowledgeChange | null;
|
|
77
|
+
content: {
|
|
78
|
+
status: "not_retained";
|
|
79
|
+
reason: "journal_metadata_only";
|
|
80
|
+
};
|
|
81
|
+
history: {
|
|
82
|
+
status: "available" | "partial" | "unavailable";
|
|
83
|
+
reason:
|
|
84
|
+
| null
|
|
85
|
+
| "structure_delta_truncated"
|
|
86
|
+
| "change_expired"
|
|
87
|
+
| "no_retained_change"
|
|
88
|
+
| "change_not_found";
|
|
89
|
+
};
|
|
90
|
+
warnings: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type KnowledgeDeltaServiceResult<T> =
|
|
94
|
+
| { success: true; data: T }
|
|
95
|
+
| { success: false; error: string; isValidation?: boolean };
|
|
96
|
+
|
|
97
|
+
const snapshot = (
|
|
98
|
+
row: DocumentChangeRow,
|
|
99
|
+
side: "old" | "new"
|
|
100
|
+
): KnowledgeChangeSnapshot | null => {
|
|
101
|
+
const prefix = side === "old" ? "old" : "new";
|
|
102
|
+
const relPath = row[`${prefix}RelPath`];
|
|
103
|
+
const docid = row[`${prefix}Docid`];
|
|
104
|
+
const uri = row[`${prefix}Uri`];
|
|
105
|
+
const sourceHash = row[`${prefix}SourceHash`];
|
|
106
|
+
const active = row[`${prefix}Active`];
|
|
107
|
+
if (
|
|
108
|
+
relPath === null ||
|
|
109
|
+
docid === null ||
|
|
110
|
+
uri === null ||
|
|
111
|
+
sourceHash === null ||
|
|
112
|
+
active === null
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
relPath,
|
|
118
|
+
docid,
|
|
119
|
+
uri,
|
|
120
|
+
sourceHash,
|
|
121
|
+
mirrorHash: row[`${prefix}MirrorHash`],
|
|
122
|
+
active,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const projectKnowledgeChange = (
|
|
127
|
+
row: DocumentChangeRow
|
|
128
|
+
): KnowledgeChange => ({
|
|
129
|
+
id: encodeDocumentChangeCursor(row.sequence),
|
|
130
|
+
kind: row.kind,
|
|
131
|
+
collection: row.collection,
|
|
132
|
+
observedAt: new Date(row.observedAtMs).toISOString(),
|
|
133
|
+
previous: snapshot(row, "old"),
|
|
134
|
+
current: snapshot(row, "new"),
|
|
135
|
+
structureDelta: row.structureDelta,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const parseSince = (
|
|
139
|
+
since: string | undefined
|
|
140
|
+
): { cursor?: string; observedAfterMs?: number } | { error: string } => {
|
|
141
|
+
if (since === undefined) return {};
|
|
142
|
+
const trimmed = since.trim();
|
|
143
|
+
if (!trimmed) return { error: "since cannot be empty" };
|
|
144
|
+
try {
|
|
145
|
+
decodeDocumentChangeCursor(trimmed);
|
|
146
|
+
return { cursor: trimmed };
|
|
147
|
+
} catch {
|
|
148
|
+
const observedAfterMs = Date.parse(trimmed);
|
|
149
|
+
return Number.isFinite(observedAfterMs)
|
|
150
|
+
? { observedAfterMs }
|
|
151
|
+
: { error: "since must be an ISO-8601 time or opaque change cursor" };
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export async function listKnowledgeChanges(
|
|
156
|
+
store: StorePort,
|
|
157
|
+
input: ListKnowledgeChangesInput = {}
|
|
158
|
+
): Promise<KnowledgeDeltaServiceResult<KnowledgeChangesResult>> {
|
|
159
|
+
if ((input.since?.length ?? 0) > 512) {
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
error: "since must be at most 512 characters",
|
|
163
|
+
isValidation: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const collection = input.collection?.trim();
|
|
167
|
+
if (
|
|
168
|
+
input.collection !== undefined &&
|
|
169
|
+
(!collection || collection.length > 256)
|
|
170
|
+
) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
error: "collection must be between 1 and 256 characters",
|
|
174
|
+
isValidation: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const limit = input.limit ?? DEFAULT_CHANGE_LIMIT;
|
|
178
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CHANGE_LIMIT) {
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
error: `limit must be between 1 and ${MAX_CHANGE_LIMIT}`,
|
|
182
|
+
isValidation: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const since = parseSince(input.since);
|
|
186
|
+
if ("error" in since) {
|
|
187
|
+
return { success: false, error: since.error, isValidation: true };
|
|
188
|
+
}
|
|
189
|
+
const listed = await store.listDocumentChanges({
|
|
190
|
+
...since,
|
|
191
|
+
collection,
|
|
192
|
+
limit,
|
|
193
|
+
});
|
|
194
|
+
if (!listed.ok) {
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
error: listed.error.message,
|
|
198
|
+
isValidation: listed.error.code === "INVALID_INPUT",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const retentionTruncated =
|
|
202
|
+
decodeDocumentChangeCursor(listed.value.earliestCursor) > 0;
|
|
203
|
+
const warnings: string[] = [];
|
|
204
|
+
if (listed.value.cursorExpired) {
|
|
205
|
+
warnings.push(
|
|
206
|
+
`Requested cursor expired; resume from ${listed.value.earliestCursor}`
|
|
207
|
+
);
|
|
208
|
+
} else if (retentionTruncated && !since.cursor) {
|
|
209
|
+
warnings.push("Earlier journal history was removed by retention");
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
success: true,
|
|
213
|
+
data: {
|
|
214
|
+
schemaVersion: "1.0",
|
|
215
|
+
changes: listed.value.changes.map(projectKnowledgeChange),
|
|
216
|
+
page: {
|
|
217
|
+
nextCursor: listed.value.nextCursor,
|
|
218
|
+
earliestCursor: listed.value.earliestCursor,
|
|
219
|
+
latestCursor: listed.value.latestCursor,
|
|
220
|
+
cursorExpired: listed.value.cursorExpired,
|
|
221
|
+
truncated: listed.value.truncated,
|
|
222
|
+
retentionTruncated,
|
|
223
|
+
},
|
|
224
|
+
warnings,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const document = (
|
|
230
|
+
row: DocumentRow
|
|
231
|
+
): KnowledgeDocument & { active: boolean } => ({
|
|
232
|
+
id: row.docid,
|
|
233
|
+
uri: row.uri,
|
|
234
|
+
title: row.title,
|
|
235
|
+
collection: row.collection,
|
|
236
|
+
relPath: row.relPath,
|
|
237
|
+
active: row.active,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
async function latestRetainedChange(
|
|
241
|
+
store: StorePort,
|
|
242
|
+
documentId: number
|
|
243
|
+
): Promise<
|
|
244
|
+
| { ok: true; row: DocumentChangeRow | null; retentionTruncated: boolean }
|
|
245
|
+
| { ok: false; error: string }
|
|
246
|
+
> {
|
|
247
|
+
let cursor: string | undefined;
|
|
248
|
+
let latest: DocumentChangeRow | null = null;
|
|
249
|
+
let retentionTruncated = false;
|
|
250
|
+
for (let pageIndex = 0; pageIndex < MAX_DIFF_SCAN_PAGES; pageIndex += 1) {
|
|
251
|
+
const page = await store.listDocumentChanges({
|
|
252
|
+
cursor,
|
|
253
|
+
documentId,
|
|
254
|
+
limit: DIFF_SCAN_PAGE_SIZE,
|
|
255
|
+
});
|
|
256
|
+
if (!page.ok) return { ok: false, error: page.error.message };
|
|
257
|
+
retentionTruncated =
|
|
258
|
+
decodeDocumentChangeCursor(page.value.earliestCursor) > 0;
|
|
259
|
+
latest = page.value.changes.at(-1) ?? latest;
|
|
260
|
+
if (!page.value.nextCursor) {
|
|
261
|
+
return { ok: true, row: latest, retentionTruncated };
|
|
262
|
+
}
|
|
263
|
+
cursor = page.value.nextCursor;
|
|
264
|
+
}
|
|
265
|
+
return { ok: false, error: "Retained change scan exceeded its bound" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function exactRetainedChange(
|
|
269
|
+
store: StorePort,
|
|
270
|
+
documentId: number,
|
|
271
|
+
changeId: string
|
|
272
|
+
): Promise<
|
|
273
|
+
| { ok: true; row: DocumentChangeRow | null; expired: boolean }
|
|
274
|
+
| { ok: false; error: string; isValidation?: boolean }
|
|
275
|
+
> {
|
|
276
|
+
let sequence: number;
|
|
277
|
+
try {
|
|
278
|
+
sequence = decodeDocumentChangeCursor(changeId);
|
|
279
|
+
} catch {
|
|
280
|
+
return { ok: false, error: "Invalid change id", isValidation: true };
|
|
281
|
+
}
|
|
282
|
+
if (sequence < 1) {
|
|
283
|
+
return { ok: false, error: "Invalid change id", isValidation: true };
|
|
284
|
+
}
|
|
285
|
+
const page = await store.listDocumentChanges({
|
|
286
|
+
cursor: encodeDocumentChangeCursor(sequence - 1),
|
|
287
|
+
documentId,
|
|
288
|
+
limit: 1,
|
|
289
|
+
});
|
|
290
|
+
if (!page.ok) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
error: page.error.message,
|
|
294
|
+
isValidation: page.error.code === "INVALID_INPUT",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const row = page.value.changes[0] ?? null;
|
|
298
|
+
return {
|
|
299
|
+
ok: true,
|
|
300
|
+
row: row?.sequence === sequence ? row : null,
|
|
301
|
+
expired: page.value.cursorExpired,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function getKnowledgeDiff(
|
|
306
|
+
store: StorePort,
|
|
307
|
+
ref: string,
|
|
308
|
+
changeId?: string
|
|
309
|
+
): Promise<KnowledgeDeltaServiceResult<KnowledgeDiffResult>> {
|
|
310
|
+
const normalizedRef = ref.trim();
|
|
311
|
+
if (!normalizedRef || normalizedRef.length > 4096) {
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
error: "ref must be between 1 and 4096 characters",
|
|
315
|
+
isValidation: true,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const normalizedChangeId = changeId?.trim();
|
|
319
|
+
if (
|
|
320
|
+
changeId !== undefined &&
|
|
321
|
+
(!normalizedChangeId || normalizedChangeId.length > 512)
|
|
322
|
+
) {
|
|
323
|
+
return {
|
|
324
|
+
success: false,
|
|
325
|
+
error: "changeId must be between 1 and 512 characters",
|
|
326
|
+
isValidation: true,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const resolved = await resolveDocRef(store, normalizedRef);
|
|
330
|
+
if ("error" in resolved) {
|
|
331
|
+
return {
|
|
332
|
+
success: false,
|
|
333
|
+
error: resolved.error,
|
|
334
|
+
isValidation: resolved.isValidation,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
let row: DocumentChangeRow | null;
|
|
338
|
+
let status: KnowledgeDiffResult["status"];
|
|
339
|
+
let history: KnowledgeDiffResult["history"];
|
|
340
|
+
const warnings = ["Source bodies are not retained in the change journal"];
|
|
341
|
+
if (normalizedChangeId) {
|
|
342
|
+
const exact = await exactRetainedChange(
|
|
343
|
+
store,
|
|
344
|
+
resolved.doc.id,
|
|
345
|
+
normalizedChangeId
|
|
346
|
+
);
|
|
347
|
+
if (!exact.ok) return { success: false, ...exact };
|
|
348
|
+
row = exact.row;
|
|
349
|
+
status = exact.expired ? "expired" : row ? "available" : "unavailable";
|
|
350
|
+
history = exact.expired
|
|
351
|
+
? { status: "unavailable", reason: "change_expired" }
|
|
352
|
+
: row
|
|
353
|
+
? row.structureDelta.truncated
|
|
354
|
+
? { status: "partial", reason: "structure_delta_truncated" }
|
|
355
|
+
: { status: "available", reason: null }
|
|
356
|
+
: { status: "unavailable", reason: "change_not_found" };
|
|
357
|
+
} else {
|
|
358
|
+
const latest = await latestRetainedChange(store, resolved.doc.id);
|
|
359
|
+
if (!latest.ok) {
|
|
360
|
+
return { success: false, error: latest.error };
|
|
361
|
+
}
|
|
362
|
+
row = latest.row;
|
|
363
|
+
status = row ? "available" : "unavailable";
|
|
364
|
+
history = row
|
|
365
|
+
? row.structureDelta.truncated
|
|
366
|
+
? { status: "partial", reason: "structure_delta_truncated" }
|
|
367
|
+
: { status: "available", reason: null }
|
|
368
|
+
: { status: "unavailable", reason: "no_retained_change" };
|
|
369
|
+
if (latest.retentionTruncated) {
|
|
370
|
+
warnings.push("Earlier journal history was removed by retention");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
success: true,
|
|
375
|
+
data: {
|
|
376
|
+
schemaVersion: "1.0",
|
|
377
|
+
status,
|
|
378
|
+
document: document(resolved.doc),
|
|
379
|
+
change: row ? projectKnowledgeChange(row) : null,
|
|
380
|
+
content: {
|
|
381
|
+
status: "not_retained",
|
|
382
|
+
reason: "journal_metadata_only",
|
|
383
|
+
},
|
|
384
|
+
history,
|
|
385
|
+
warnings,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export { analyzeKnowledgeImpact } from "./knowledge-impact";
|
|
391
|
+
export type {
|
|
392
|
+
KnowledgeImpactEvidenceStep,
|
|
393
|
+
KnowledgeImpactInput,
|
|
394
|
+
KnowledgeImpactResult,
|
|
395
|
+
} from "./knowledge-impact";
|