@gmickel/gno 2.7.1 → 2.8.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 +3 -2
- package/assets/skill/SKILL.md +8 -1
- package/assets/skill/cli-reference.md +8 -1
- package/assets/skill/examples.md +2 -1
- package/assets/skill/mcp-reference.md +3 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +48 -6
- package/spec/db/schema.sql +0 -1
- package/spec/mcp.md +18 -3
- package/spec/output-schemas/audit-report.schema.json +18 -4
- package/spec/output-schemas/backlinks.schema.json +4 -0
- package/spec/output-schemas/collection-list.schema.json +13 -0
- package/spec/output-schemas/graph.schema.json +2 -0
- package/spec/output-schemas/links-list.schema.json +4 -0
- package/spec/output-schemas/status.schema.json +15 -0
- package/src/cli/commands/audit.ts +23 -4
- package/src/cli/commands/collection/list.ts +39 -5
- package/src/cli/commands/embed.ts +3 -3
- package/src/cli/commands/links.ts +34 -131
- package/src/cli/commands/shared.ts +7 -0
- package/src/cli/commands/status.ts +5 -0
- package/src/cli/program.ts +12 -2
- package/src/config/loader.ts +43 -0
- package/src/config/types.ts +8 -0
- package/src/core/audit-contract.ts +16 -4
- package/src/core/audit-freshness.ts +11 -1
- package/src/core/audit-links.ts +145 -25
- package/src/core/audit-provenance.ts +11 -4
- package/src/core/audit-workspace.ts +19 -4
- package/src/core/audit.ts +67 -15
- package/src/core/context-compiler.ts +3 -0
- package/src/core/context-evidence.ts +11 -0
- package/src/core/graph-edge-confidence.ts +23 -1
- package/src/core/host-paths.ts +1 -0
- package/src/core/knowledge-impact.ts +28 -0
- package/src/core/link-workspace.ts +324 -0
- package/src/core/retrieval-replay-candidate.ts +6 -0
- package/src/core/retrieval-trace-request.ts +3 -0
- package/src/index.ts +14 -1
- package/src/ingestion/graph-reconciliation.ts +77 -15
- package/src/ingestion/source-availability/darwin-path.ts +9 -3
- package/src/ingestion/sync.ts +22 -1
- package/src/ingestion/types.ts +14 -0
- package/src/llm/inference-scope.ts +4 -3
- package/src/mcp/http-egress.ts +42 -3
- package/src/mcp/tools/audit.ts +11 -2
- package/src/mcp/tools/changes.ts +1 -0
- package/src/mcp/tools/links.ts +3 -0
- package/src/mcp/tools/sessions.ts +33 -4
- package/src/mcp/tools/status.ts +3 -0
- package/src/pipeline/expansion.ts +19 -31
- package/src/pipeline/graph-retrieval.ts +22 -2
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/types.ts +6 -3
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/links.ts +13 -0
- package/src/serve/routes/sessions.ts +41 -53
- package/src/sessions/config-refresh.ts +111 -0
- package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
- package/src/store/migrations/034-collection-link-workspace.ts +47 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +390 -231
- package/src/store/sqlite/eligibility.ts +8 -2
- package/src/store/sqlite/graph-link-resolver.ts +252 -5
- package/src/store/sqlite/graph-neighbors.ts +147 -40
- package/src/store/sqlite/graph-reference-state.ts +13 -2
- package/src/store/sqlite/workspace-link-resolver.ts +654 -0
- package/src/store/types.ts +49 -3
- package/src/store/vector/stats.ts +1 -1
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
|
@@ -138,8 +138,12 @@ import {
|
|
|
138
138
|
classifyResolvedGraphEdge,
|
|
139
139
|
mergeGraphEdgeAudit,
|
|
140
140
|
} from "../../core/graph-edge-confidence";
|
|
141
|
-
import { buildWikiBestMatchSubquery } from "../../core/graph-resolver";
|
|
142
141
|
import { buildContentPrefilterNeedles } from "../../core/link-relevance";
|
|
142
|
+
import {
|
|
143
|
+
detectCollectionWorkspace,
|
|
144
|
+
detectNestedWorkspacePrefixes,
|
|
145
|
+
type LinkWorkspaceSource,
|
|
146
|
+
} from "../../core/link-workspace";
|
|
143
147
|
import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
|
|
144
148
|
import {
|
|
145
149
|
TYPED_METADATA_INGEST_VERSION,
|
|
@@ -213,7 +217,12 @@ import {
|
|
|
213
217
|
applyGraphEdges,
|
|
214
218
|
type DesiredGraphEdge,
|
|
215
219
|
} from "./graph-edge-application";
|
|
216
|
-
import {
|
|
220
|
+
import {
|
|
221
|
+
type GraphLinkSourceIdentity,
|
|
222
|
+
isTraversableResolution,
|
|
223
|
+
linkSourceIdentity,
|
|
224
|
+
resolveGraphLinkTargets,
|
|
225
|
+
} from "./graph-link-resolver";
|
|
217
226
|
import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
|
|
218
227
|
import { createGraphReferenceStore } from "./graph-reference-state";
|
|
219
228
|
import {
|
|
@@ -243,6 +252,11 @@ import {
|
|
|
243
252
|
listTraces as listStoredTraces,
|
|
244
253
|
mergeTraceEgressLineage as mergeStoredTraceEgressLineage,
|
|
245
254
|
} from "./retrieval-trace-store";
|
|
255
|
+
import {
|
|
256
|
+
loadLinkWorkspaceMemberships,
|
|
257
|
+
workspaceKeyForDocument,
|
|
258
|
+
workspaceMemberCollections,
|
|
259
|
+
} from "./workspace-link-resolver";
|
|
246
260
|
|
|
247
261
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
262
|
// FTS5 Query Escaping
|
|
@@ -903,11 +917,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
903
917
|
INSERT INTO collections (
|
|
904
918
|
name, path, pattern, include, exclude, update_cmd, language_hint,
|
|
905
919
|
egress_policy, egress_policy_source, egress_policy_revision,
|
|
920
|
+
real_path, workspace_root, workspace_source,
|
|
906
921
|
synced_at
|
|
907
922
|
)
|
|
908
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
923
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
909
924
|
ON CONFLICT(name) DO UPDATE SET
|
|
910
925
|
path = excluded.path,
|
|
926
|
+
${COLLECTION_WORKSPACE_UPSERT_SET}
|
|
911
927
|
pattern = excluded.pattern,
|
|
912
928
|
include = excluded.include,
|
|
913
929
|
exclude = excluded.exclude,
|
|
@@ -936,6 +952,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
936
952
|
|
|
937
953
|
for (const c of collections) {
|
|
938
954
|
const egress = resolveConfiguredEgressPolicy(c);
|
|
955
|
+
const workspace = detectCollectionWorkspace(c);
|
|
939
956
|
stmt.run(
|
|
940
957
|
c.name,
|
|
941
958
|
c.path,
|
|
@@ -946,7 +963,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
946
963
|
c.languageHint ?? null,
|
|
947
964
|
egress.policy,
|
|
948
965
|
egress.source,
|
|
949
|
-
c.egressPolicyRevision ?? 0
|
|
966
|
+
c.egressPolicyRevision ?? 0,
|
|
967
|
+
workspace.realPath,
|
|
968
|
+
workspace.root,
|
|
969
|
+
workspace.source
|
|
950
970
|
);
|
|
951
971
|
}
|
|
952
972
|
});
|
|
@@ -971,11 +991,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
971
991
|
INSERT INTO collections (
|
|
972
992
|
name, path, pattern, include, exclude, update_cmd, language_hint,
|
|
973
993
|
egress_policy, egress_policy_source, egress_policy_revision,
|
|
994
|
+
real_path, workspace_root, workspace_source,
|
|
974
995
|
synced_at
|
|
975
996
|
)
|
|
976
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
997
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
977
998
|
ON CONFLICT(name) DO UPDATE SET
|
|
978
999
|
path = excluded.path,
|
|
1000
|
+
${COLLECTION_WORKSPACE_UPSERT_SET}
|
|
979
1001
|
pattern = excluded.pattern,
|
|
980
1002
|
include = excluded.include,
|
|
981
1003
|
exclude = excluded.exclude,
|
|
@@ -1001,6 +1023,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1001
1023
|
END,
|
|
1002
1024
|
synced_at = datetime('now')
|
|
1003
1025
|
WHERE collections.path IS NOT excluded.path
|
|
1026
|
+
OR collections.real_path IS NOT excluded.real_path
|
|
1027
|
+
OR collections.workspace_root IS NOT excluded.workspace_root
|
|
1028
|
+
OR collections.workspace_source IS NOT excluded.workspace_source
|
|
1004
1029
|
OR collections.pattern IS NOT excluded.pattern
|
|
1005
1030
|
OR collections.include IS NOT excluded.include
|
|
1006
1031
|
OR collections.exclude IS NOT excluded.exclude
|
|
@@ -1021,6 +1046,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1021
1046
|
const transaction = db.transaction(() => {
|
|
1022
1047
|
for (const collection of collections) {
|
|
1023
1048
|
const egress = resolveConfiguredEgressPolicy(collection);
|
|
1049
|
+
const workspace = detectCollectionWorkspace(collection);
|
|
1024
1050
|
stmt.run(
|
|
1025
1051
|
collection.name,
|
|
1026
1052
|
collection.path,
|
|
@@ -1035,7 +1061,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1035
1061
|
collection.languageHint ?? null,
|
|
1036
1062
|
egress.policy,
|
|
1037
1063
|
egress.source,
|
|
1038
|
-
collection.egressPolicyRevision ?? 0
|
|
1064
|
+
collection.egressPolicyRevision ?? 0,
|
|
1065
|
+
workspace.realPath,
|
|
1066
|
+
workspace.root,
|
|
1067
|
+
workspace.source
|
|
1039
1068
|
);
|
|
1040
1069
|
}
|
|
1041
1070
|
});
|
|
@@ -1129,6 +1158,62 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1129
1158
|
}
|
|
1130
1159
|
}
|
|
1131
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* Re-detect nested vaults (`.obsidian/` below the collection root) from the
|
|
1163
|
+
* directories of the collection's active documents. Returns whether the
|
|
1164
|
+
* stored membership changed.
|
|
1165
|
+
*/
|
|
1166
|
+
async refreshCollectionNestedWorkspaces(
|
|
1167
|
+
collection: string
|
|
1168
|
+
): Promise<StoreResult<boolean>> {
|
|
1169
|
+
try {
|
|
1170
|
+
const db = this.ensureOpen();
|
|
1171
|
+
const row = db
|
|
1172
|
+
.query<
|
|
1173
|
+
{
|
|
1174
|
+
real_path: string | null;
|
|
1175
|
+
workspace_source: LinkWorkspaceSource | null;
|
|
1176
|
+
workspace_nested: string | null;
|
|
1177
|
+
},
|
|
1178
|
+
[string]
|
|
1179
|
+
>(
|
|
1180
|
+
"SELECT real_path, workspace_source, workspace_nested FROM collections WHERE name = ?"
|
|
1181
|
+
)
|
|
1182
|
+
.get(collection);
|
|
1183
|
+
if (!row) return ok(false);
|
|
1184
|
+
const eligible =
|
|
1185
|
+
row.real_path !== null &&
|
|
1186
|
+
row.workspace_source !== "disabled" &&
|
|
1187
|
+
row.workspace_source !== "unavailable";
|
|
1188
|
+
const nested = eligible
|
|
1189
|
+
? detectNestedWorkspacePrefixes(
|
|
1190
|
+
row.real_path as string,
|
|
1191
|
+
db
|
|
1192
|
+
.query<{ rel_path: string }, [string]>(
|
|
1193
|
+
"SELECT rel_path FROM documents WHERE collection = ? AND active = 1"
|
|
1194
|
+
)
|
|
1195
|
+
.all(collection)
|
|
1196
|
+
.map((document) => document.rel_path)
|
|
1197
|
+
)
|
|
1198
|
+
: [];
|
|
1199
|
+
const next = nested.length > 0 ? JSON.stringify(nested) : null;
|
|
1200
|
+
if (next === row.workspace_nested) return ok(false);
|
|
1201
|
+
db.run("UPDATE collections SET workspace_nested = ? WHERE name = ?", [
|
|
1202
|
+
next,
|
|
1203
|
+
collection,
|
|
1204
|
+
]);
|
|
1205
|
+
return ok(true);
|
|
1206
|
+
} catch (cause) {
|
|
1207
|
+
return err(
|
|
1208
|
+
"QUERY_FAILED",
|
|
1209
|
+
cause instanceof Error
|
|
1210
|
+
? cause.message
|
|
1211
|
+
: "Failed to refresh nested link workspaces",
|
|
1212
|
+
cause
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1132
1217
|
async getContexts(): Promise<StoreResult<ContextRow[]>> {
|
|
1133
1218
|
try {
|
|
1134
1219
|
const db = this.ensureOpen();
|
|
@@ -2183,7 +2268,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2183
2268
|
params.push(options.collection);
|
|
2184
2269
|
}
|
|
2185
2270
|
|
|
2186
|
-
const sql = `SELECT * FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id`;
|
|
2271
|
+
const sql = `SELECT * FROM documents INDEXED BY idx_documents_mirror_hash WHERE ${clauses.join(" AND ")} ORDER BY id`;
|
|
2187
2272
|
rows.push(...db.query<DbDocumentRow, string[]>(sql).all(...params));
|
|
2188
2273
|
}
|
|
2189
2274
|
|
|
@@ -3786,12 +3871,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3786
3871
|
|
|
3787
3872
|
/**
|
|
3788
3873
|
* Get backlinks pointing to a document.
|
|
3789
|
-
*
|
|
3874
|
+
* Collection-scoped links match target_ref_norm (wiki=normalized title with
|
|
3875
|
+
* path fallbacks, markdown=rel_path). Plain wiki links from a document in a
|
|
3876
|
+
* link workspace count only when the shared resolver lands them on this
|
|
3877
|
+
* document (tied links never count). `collection` / `collections` restrict
|
|
3878
|
+
* the SOURCE collections; unset means every collection.
|
|
3790
3879
|
* Only returns links from active source documents.
|
|
3791
3880
|
*/
|
|
3792
3881
|
async getBacklinksForDoc(
|
|
3793
3882
|
documentId: number,
|
|
3794
|
-
options?: { collection?: string }
|
|
3883
|
+
options?: { collection?: string; collections?: string[] }
|
|
3795
3884
|
): Promise<StoreResult<BacklinkRow[]>> {
|
|
3796
3885
|
try {
|
|
3797
3886
|
const db = this.ensureOpen();
|
|
@@ -3841,17 +3930,34 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3841
3930
|
addVariantsWithBasename(relPathKey);
|
|
3842
3931
|
|
|
3843
3932
|
interface DbBacklinkRow {
|
|
3933
|
+
link_id: number;
|
|
3844
3934
|
source_doc_id: number;
|
|
3845
3935
|
docid: string;
|
|
3846
3936
|
uri: string;
|
|
3847
3937
|
title: string | null;
|
|
3938
|
+
source_collection: string;
|
|
3939
|
+
source_rel_path: string;
|
|
3940
|
+
target_ref_norm: string;
|
|
3941
|
+
target_collection: string | null;
|
|
3848
3942
|
link_text: string | null;
|
|
3849
3943
|
start_line: number;
|
|
3850
3944
|
start_col: number;
|
|
3851
3945
|
}
|
|
3852
3946
|
|
|
3853
3947
|
const targetCollection = target.collection;
|
|
3854
|
-
const
|
|
3948
|
+
const sourceAllowlist =
|
|
3949
|
+
options?.collections ??
|
|
3950
|
+
(options?.collection ? [options.collection] : undefined);
|
|
3951
|
+
const sourceScopeSql = sourceAllowlist
|
|
3952
|
+
? sourceAllowlist.length === 0
|
|
3953
|
+
? "AND 0"
|
|
3954
|
+
: `AND src.collection IN (${sourceAllowlist.map(() => "?").join(",")})`
|
|
3955
|
+
: "";
|
|
3956
|
+
const sourceScopeParams = sourceAllowlist ?? [];
|
|
3957
|
+
const backlinkColumns = `dl.id AS link_id, dl.source_doc_id, src.docid,
|
|
3958
|
+
src.uri, src.title, src.collection AS source_collection,
|
|
3959
|
+
src.rel_path AS source_rel_path, dl.target_ref_norm,
|
|
3960
|
+
dl.target_collection, dl.link_text, dl.start_line, dl.start_col`;
|
|
3855
3961
|
|
|
3856
3962
|
// Query wiki backlinks (link_type='wiki') with path-style fallbacks
|
|
3857
3963
|
// NULL target_collection means "same collection as source" - enforce this in SQL
|
|
@@ -3881,7 +3987,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3881
3987
|
wikiConditions.length > 0
|
|
3882
3988
|
? db
|
|
3883
3989
|
.query<DbBacklinkRow, string[]>(
|
|
3884
|
-
`SELECT
|
|
3990
|
+
`SELECT ${backlinkColumns}
|
|
3885
3991
|
FROM doc_links dl
|
|
3886
3992
|
JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
|
|
3887
3993
|
WHERE dl.link_type = 'wiki'
|
|
@@ -3890,22 +3996,112 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3890
3996
|
(dl.target_collection IS NULL AND src.collection = ?)
|
|
3891
3997
|
OR dl.target_collection = ?
|
|
3892
3998
|
)
|
|
3893
|
-
${
|
|
3999
|
+
${sourceScopeSql}
|
|
3894
4000
|
ORDER BY src.uri, dl.start_line, dl.start_col`
|
|
3895
4001
|
)
|
|
3896
4002
|
.all(
|
|
3897
4003
|
...wikiParams,
|
|
3898
4004
|
targetCollection,
|
|
3899
4005
|
targetCollection,
|
|
3900
|
-
...
|
|
4006
|
+
...sourceScopeParams
|
|
3901
4007
|
)
|
|
3902
4008
|
: [];
|
|
3903
4009
|
|
|
4010
|
+
// Workspace candidates: plain links from any member collection whose
|
|
4011
|
+
// last segment names this file. Resolution decides which count.
|
|
4012
|
+
const memberships = loadLinkWorkspaceMemberships(db);
|
|
4013
|
+
const targetWsKey = workspaceKeyForDocument(
|
|
4014
|
+
memberships,
|
|
4015
|
+
target.collection,
|
|
4016
|
+
target.rel_path
|
|
4017
|
+
);
|
|
4018
|
+
const workspaceBacklinks: DbBacklinkRow[] = [];
|
|
4019
|
+
if (targetWsKey !== null) {
|
|
4020
|
+
const members = workspaceMemberCollections(
|
|
4021
|
+
memberships,
|
|
4022
|
+
new Set([targetWsKey])
|
|
4023
|
+
);
|
|
4024
|
+
const base = stripWikiMdExt(
|
|
4025
|
+
normalizeWikiName(target.rel_path.split("/").pop() ?? target.rel_path)
|
|
4026
|
+
);
|
|
4027
|
+
const escapeLike = (value: string): string =>
|
|
4028
|
+
value
|
|
4029
|
+
.replaceAll("\\", "\\\\")
|
|
4030
|
+
.replaceAll("%", "\\%")
|
|
4031
|
+
.replaceAll("_", "\\_");
|
|
4032
|
+
if (members.length > 0) {
|
|
4033
|
+
workspaceBacklinks.push(
|
|
4034
|
+
...db
|
|
4035
|
+
.query<DbBacklinkRow, string[]>(
|
|
4036
|
+
`SELECT ${backlinkColumns}
|
|
4037
|
+
FROM doc_links dl
|
|
4038
|
+
JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
|
|
4039
|
+
WHERE dl.link_type = 'wiki'
|
|
4040
|
+
AND dl.target_collection IS NULL
|
|
4041
|
+
AND src.collection IN (${members.map(() => "?").join(",")})
|
|
4042
|
+
AND (dl.target_ref_norm IN (?, ?)
|
|
4043
|
+
OR dl.target_ref_norm LIKE ? ESCAPE '\\'
|
|
4044
|
+
OR dl.target_ref_norm LIKE ? ESCAPE '\\'
|
|
4045
|
+
OR dl.target_ref_norm IN (${[...keySet].map(() => "?").join(",") || "NULL"}))
|
|
4046
|
+
${sourceScopeSql}
|
|
4047
|
+
ORDER BY src.uri, dl.start_line, dl.start_col`
|
|
4048
|
+
)
|
|
4049
|
+
.all(
|
|
4050
|
+
...members,
|
|
4051
|
+
base,
|
|
4052
|
+
`${base}.md`,
|
|
4053
|
+
`%/${escapeLike(base)}`,
|
|
4054
|
+
`%/${escapeLike(`${base}.md`)}`,
|
|
4055
|
+
...keySet,
|
|
4056
|
+
...sourceScopeParams
|
|
4057
|
+
)
|
|
4058
|
+
);
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
const candidates = new Map<number, DbBacklinkRow>();
|
|
4062
|
+
for (const row of [...wikiBacklinks, ...workspaceBacklinks]) {
|
|
4063
|
+
candidates.set(row.link_id, row);
|
|
4064
|
+
}
|
|
4065
|
+
const candidateRows = [...candidates.values()];
|
|
4066
|
+
const resolutions = resolveGraphLinkTargets(
|
|
4067
|
+
db,
|
|
4068
|
+
candidateRows.map((row) => ({
|
|
4069
|
+
targetRefNorm: row.target_ref_norm,
|
|
4070
|
+
targetCollection: row.target_collection ?? row.source_collection,
|
|
4071
|
+
linkType: "wiki" as const,
|
|
4072
|
+
source: linkSourceIdentity(row),
|
|
4073
|
+
}))
|
|
4074
|
+
);
|
|
4075
|
+
const legacyLinkIds = new Set(wikiBacklinks.map((row) => row.link_id));
|
|
4076
|
+
const resolvedWikiBacklinks = candidateRows
|
|
4077
|
+
.filter((row, index) => {
|
|
4078
|
+
const workspaceLink =
|
|
4079
|
+
!row.target_collection &&
|
|
4080
|
+
workspaceKeyForDocument(
|
|
4081
|
+
memberships,
|
|
4082
|
+
row.source_collection,
|
|
4083
|
+
row.source_rel_path
|
|
4084
|
+
) !== null;
|
|
4085
|
+
// Collection-scoped links keep their key-match semantics.
|
|
4086
|
+
if (!workspaceLink) return legacyLinkIds.has(row.link_id);
|
|
4087
|
+
const resolution = resolutions[index];
|
|
4088
|
+
return (
|
|
4089
|
+
isTraversableResolution(resolution) &&
|
|
4090
|
+
resolution.targetId === documentId
|
|
4091
|
+
);
|
|
4092
|
+
})
|
|
4093
|
+
.sort(
|
|
4094
|
+
(left, right) =>
|
|
4095
|
+
(left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0) ||
|
|
4096
|
+
left.start_line - right.start_line ||
|
|
4097
|
+
left.start_col - right.start_col
|
|
4098
|
+
);
|
|
4099
|
+
|
|
3904
4100
|
// Query markdown backlinks (link_type='markdown')
|
|
3905
4101
|
// NULL target_collection means "same collection as source" - enforce this in SQL
|
|
3906
4102
|
const mdBacklinks = db
|
|
3907
4103
|
.query<DbBacklinkRow, string[]>(
|
|
3908
|
-
`SELECT
|
|
4104
|
+
`SELECT ${backlinkColumns}
|
|
3909
4105
|
FROM doc_links dl
|
|
3910
4106
|
JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
|
|
3911
4107
|
WHERE dl.link_type = 'markdown'
|
|
@@ -3914,25 +4110,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3914
4110
|
(dl.target_collection IS NULL AND src.collection = ?)
|
|
3915
4111
|
OR dl.target_collection = ?
|
|
3916
4112
|
)
|
|
3917
|
-
${
|
|
4113
|
+
${sourceScopeSql}
|
|
3918
4114
|
ORDER BY src.uri, dl.start_line, dl.start_col`
|
|
3919
4115
|
)
|
|
3920
4116
|
.all(
|
|
3921
4117
|
target.rel_path,
|
|
3922
4118
|
targetCollection,
|
|
3923
4119
|
targetCollection,
|
|
3924
|
-
...
|
|
4120
|
+
...sourceScopeParams
|
|
3925
4121
|
);
|
|
3926
4122
|
|
|
3927
|
-
const allBacklinks = [...
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
4123
|
+
const allBacklinks = [...resolvedWikiBacklinks, ...mdBacklinks].map(
|
|
4124
|
+
(r) => ({
|
|
4125
|
+
sourceDocId: r.source_doc_id,
|
|
4126
|
+
sourceDocid: r.docid,
|
|
4127
|
+
sourceDocUri: r.uri,
|
|
4128
|
+
sourceDocTitle: r.title,
|
|
4129
|
+
sourceCollection: r.source_collection,
|
|
4130
|
+
linkText: r.link_text,
|
|
4131
|
+
startLine: r.start_line,
|
|
4132
|
+
startCol: r.start_col,
|
|
4133
|
+
})
|
|
4134
|
+
);
|
|
3936
4135
|
|
|
3937
4136
|
return ok(allBacklinks);
|
|
3938
4137
|
} catch (cause) {
|
|
@@ -3951,206 +4150,64 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3951
4150
|
targetRefNorm: string;
|
|
3952
4151
|
targetCollection: string;
|
|
3953
4152
|
linkType: "wiki" | "markdown";
|
|
4153
|
+
source?: GraphLinkSourceIdentity;
|
|
3954
4154
|
}>
|
|
3955
4155
|
): Promise<
|
|
3956
4156
|
StoreResult<
|
|
3957
|
-
Array<{
|
|
3958
|
-
>
|
|
3959
|
-
> {
|
|
3960
|
-
try {
|
|
3961
|
-
const db = this.ensureOpen();
|
|
3962
|
-
|
|
3963
|
-
const results: Array<{
|
|
4157
|
+
Array<{
|
|
3964
4158
|
docid: string;
|
|
3965
4159
|
uri: string;
|
|
3966
4160
|
title: string | null;
|
|
3967
|
-
} | null> = Array.from({ length: targets.length }, () => null);
|
|
3968
|
-
|
|
3969
|
-
const wikiTargets: Array<{
|
|
3970
|
-
idx: number;
|
|
3971
4161
|
collection: string;
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
4162
|
+
} | null>
|
|
4163
|
+
>
|
|
4164
|
+
> {
|
|
4165
|
+
try {
|
|
4166
|
+
const db = this.ensureOpen();
|
|
4167
|
+
const resolutions = resolveGraphLinkTargets(db, targets);
|
|
4168
|
+
const ids = [
|
|
4169
|
+
...new Set(
|
|
4170
|
+
resolutions
|
|
4171
|
+
.filter(isTraversableResolution)
|
|
4172
|
+
.map((resolution) => resolution.targetId)
|
|
4173
|
+
),
|
|
4174
|
+
];
|
|
4175
|
+
const byId = new Map<
|
|
4176
|
+
number,
|
|
4177
|
+
{ docid: string; uri: string; title: string | null; collection: string }
|
|
4178
|
+
>();
|
|
4179
|
+
for (let offset = 0; offset < ids.length; offset += 900) {
|
|
4180
|
+
const batch = ids.slice(offset, offset + 900);
|
|
4181
|
+
for (const row of db
|
|
4182
|
+
.query<
|
|
4183
|
+
{
|
|
4184
|
+
id: number;
|
|
4185
|
+
docid: string;
|
|
4186
|
+
uri: string;
|
|
4187
|
+
title: string | null;
|
|
4188
|
+
collection: string;
|
|
4189
|
+
},
|
|
4190
|
+
number[]
|
|
4191
|
+
>(
|
|
4192
|
+
`SELECT id, docid, uri, title, collection FROM documents WHERE id IN (${batch.map(() => "?").join(",")})`
|
|
4193
|
+
)
|
|
4194
|
+
.all(...batch)) {
|
|
4195
|
+
byId.set(row.id, {
|
|
4196
|
+
docid: row.docid,
|
|
4197
|
+
uri: row.uri,
|
|
4198
|
+
title: row.title,
|
|
4199
|
+
collection: row.collection,
|
|
3995
4200
|
});
|
|
3996
4201
|
}
|
|
3997
4202
|
}
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
const MAX_SQL_PARAMS = 900;
|
|
4008
|
-
const wikiBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 4));
|
|
4009
|
-
const mdBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 3));
|
|
4010
|
-
|
|
4011
|
-
const titleExpr = "lower(trim(d.title))";
|
|
4012
|
-
const relExpr = "lower(d.rel_path)";
|
|
4013
|
-
const suffixMatchExprExpr = (
|
|
4014
|
-
targetExpr: string,
|
|
4015
|
-
valueExpr: string
|
|
4016
|
-
): string =>
|
|
4017
|
-
`(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
|
|
4018
|
-
AND (length(${targetExpr}) = length(${valueExpr})
|
|
4019
|
-
OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
|
|
4020
|
-
|
|
4021
|
-
if (wikiTargets.length > 0) {
|
|
4022
|
-
for (const batch of chunkArray(wikiTargets, wikiBatchSize)) {
|
|
4023
|
-
const valuesClause = batch.map(() => "(?, ?, ?, ?)").join(", ");
|
|
4024
|
-
const wikiParams = batch.flatMap((t) => [
|
|
4025
|
-
t.idx,
|
|
4026
|
-
t.collection,
|
|
4027
|
-
t.baseRef,
|
|
4028
|
-
t.baseRefMd,
|
|
4029
|
-
]);
|
|
4030
|
-
|
|
4031
|
-
const baseRefExpr = "t.base_ref";
|
|
4032
|
-
const baseRefMdExpr = "t.base_ref_md";
|
|
4033
|
-
const wikiWhere = `
|
|
4034
|
-
${titleExpr} = ${baseRefExpr}
|
|
4035
|
-
OR ${titleExpr} = ${baseRefMdExpr}
|
|
4036
|
-
OR ${suffixMatchExprExpr(baseRefExpr, titleExpr)}
|
|
4037
|
-
OR ${suffixMatchExprExpr(baseRefMdExpr, `${titleExpr} || '.md'`)}
|
|
4038
|
-
OR ${relExpr} = ${baseRefExpr}
|
|
4039
|
-
OR ${relExpr} = ${baseRefMdExpr}
|
|
4040
|
-
OR ${suffixMatchExprExpr(relExpr, baseRefMdExpr)}
|
|
4041
|
-
OR ${suffixMatchExprExpr(relExpr, baseRefExpr)}
|
|
4042
|
-
OR ${suffixMatchExprExpr(baseRefMdExpr, relExpr)}
|
|
4043
|
-
OR ${suffixMatchExprExpr(baseRefExpr, relExpr)}
|
|
4044
|
-
`;
|
|
4045
|
-
|
|
4046
|
-
const wikiRank = `CASE
|
|
4047
|
-
WHEN ${titleExpr} = ${baseRefExpr} THEN 1
|
|
4048
|
-
WHEN ${titleExpr} = ${baseRefMdExpr} THEN 2
|
|
4049
|
-
WHEN ${suffixMatchExprExpr(baseRefExpr, titleExpr)} THEN 3
|
|
4050
|
-
WHEN ${suffixMatchExprExpr(
|
|
4051
|
-
baseRefMdExpr,
|
|
4052
|
-
`${titleExpr} || '.md'`
|
|
4053
|
-
)} THEN 4
|
|
4054
|
-
WHEN ${relExpr} = ${baseRefExpr} THEN 5
|
|
4055
|
-
WHEN ${relExpr} = ${baseRefMdExpr} THEN 6
|
|
4056
|
-
WHEN ${suffixMatchExprExpr(relExpr, baseRefMdExpr)} THEN 7
|
|
4057
|
-
WHEN ${suffixMatchExprExpr(relExpr, baseRefExpr)} THEN 8
|
|
4058
|
-
WHEN ${suffixMatchExprExpr(baseRefMdExpr, relExpr)} THEN 9
|
|
4059
|
-
WHEN ${suffixMatchExprExpr(baseRefExpr, relExpr)} THEN 10
|
|
4060
|
-
ELSE 99
|
|
4061
|
-
END`;
|
|
4062
|
-
|
|
4063
|
-
const wikiQuery = `
|
|
4064
|
-
WITH targets(idx, collection, base_ref, base_ref_md) AS (
|
|
4065
|
-
VALUES ${valuesClause}
|
|
4066
|
-
),
|
|
4067
|
-
candidates AS (
|
|
4068
|
-
SELECT
|
|
4069
|
-
t.idx,
|
|
4070
|
-
d.docid,
|
|
4071
|
-
d.uri,
|
|
4072
|
-
d.title,
|
|
4073
|
-
d.id as doc_id,
|
|
4074
|
-
${wikiRank} as rank
|
|
4075
|
-
FROM targets t
|
|
4076
|
-
JOIN documents d ON d.active = 1 AND d.collection = t.collection
|
|
4077
|
-
WHERE ${wikiWhere}
|
|
4078
|
-
),
|
|
4079
|
-
ranked AS (
|
|
4080
|
-
SELECT *,
|
|
4081
|
-
ROW_NUMBER() OVER (PARTITION BY idx ORDER BY rank, doc_id) as rn
|
|
4082
|
-
FROM candidates
|
|
4083
|
-
)
|
|
4084
|
-
SELECT idx, docid, uri, title
|
|
4085
|
-
FROM ranked
|
|
4086
|
-
WHERE rn = 1
|
|
4087
|
-
`;
|
|
4088
|
-
|
|
4089
|
-
const wikiRows = db
|
|
4090
|
-
.query<
|
|
4091
|
-
{ idx: number; docid: string; uri: string; title: string | null },
|
|
4092
|
-
(string | number)[]
|
|
4093
|
-
>(wikiQuery)
|
|
4094
|
-
.all(...wikiParams);
|
|
4095
|
-
|
|
4096
|
-
for (const row of wikiRows) {
|
|
4097
|
-
results[row.idx] = {
|
|
4098
|
-
docid: row.docid,
|
|
4099
|
-
uri: row.uri,
|
|
4100
|
-
title: row.title,
|
|
4101
|
-
};
|
|
4102
|
-
}
|
|
4103
|
-
}
|
|
4104
|
-
}
|
|
4105
|
-
|
|
4106
|
-
if (mdTargets.length > 0) {
|
|
4107
|
-
for (const batch of chunkArray(mdTargets, mdBatchSize)) {
|
|
4108
|
-
const valuesClause = batch.map(() => "(?, ?, ?)").join(", ");
|
|
4109
|
-
const mdParams = batch.flatMap((t) => [
|
|
4110
|
-
t.idx,
|
|
4111
|
-
t.collection,
|
|
4112
|
-
t.relPath,
|
|
4113
|
-
]);
|
|
4114
|
-
const mdQuery = `
|
|
4115
|
-
WITH targets(idx, collection, rel_path) AS (
|
|
4116
|
-
VALUES ${valuesClause}
|
|
4117
|
-
),
|
|
4118
|
-
ranked AS (
|
|
4119
|
-
SELECT
|
|
4120
|
-
t.idx,
|
|
4121
|
-
d.docid,
|
|
4122
|
-
d.uri,
|
|
4123
|
-
d.title,
|
|
4124
|
-
d.id as doc_id,
|
|
4125
|
-
ROW_NUMBER() OVER (PARTITION BY t.idx ORDER BY d.id) as rn
|
|
4126
|
-
FROM targets t
|
|
4127
|
-
JOIN documents d ON d.active = 1
|
|
4128
|
-
AND d.collection = t.collection
|
|
4129
|
-
AND d.rel_path = t.rel_path
|
|
4130
|
-
)
|
|
4131
|
-
SELECT idx, docid, uri, title
|
|
4132
|
-
FROM ranked
|
|
4133
|
-
WHERE rn = 1
|
|
4134
|
-
`;
|
|
4135
|
-
|
|
4136
|
-
const mdRows = db
|
|
4137
|
-
.query<
|
|
4138
|
-
{ idx: number; docid: string; uri: string; title: string | null },
|
|
4139
|
-
(string | number)[]
|
|
4140
|
-
>(mdQuery)
|
|
4141
|
-
.all(...mdParams);
|
|
4142
|
-
|
|
4143
|
-
for (const row of mdRows) {
|
|
4144
|
-
results[row.idx] = {
|
|
4145
|
-
docid: row.docid,
|
|
4146
|
-
uri: row.uri,
|
|
4147
|
-
title: row.title,
|
|
4148
|
-
};
|
|
4149
|
-
}
|
|
4150
|
-
}
|
|
4151
|
-
}
|
|
4152
|
-
|
|
4153
|
-
return ok(results);
|
|
4203
|
+
// Tied workspace links are ambiguous: reported unresolved, never guessed.
|
|
4204
|
+
return ok(
|
|
4205
|
+
resolutions.map((resolution) =>
|
|
4206
|
+
isTraversableResolution(resolution)
|
|
4207
|
+
? (byId.get(resolution.targetId) ?? null)
|
|
4208
|
+
: null
|
|
4209
|
+
)
|
|
4210
|
+
);
|
|
4154
4211
|
} catch (cause) {
|
|
4155
4212
|
return err(
|
|
4156
4213
|
"QUERY_FAILED",
|
|
@@ -4757,6 +4814,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4757
4814
|
? normalizeDocEdgeType(options.edgeType)
|
|
4758
4815
|
: undefined;
|
|
4759
4816
|
|
|
4817
|
+
// Collection allowlist: every visited node must be in scope, so a
|
|
4818
|
+
// forbidden collection can never act as a traversal bridge. Names are
|
|
4819
|
+
// validated collection identifiers, inlined as quoted literals.
|
|
4820
|
+
const scopeCollections = options.collections
|
|
4821
|
+
? [...new Set(options.collections)].filter((name) =>
|
|
4822
|
+
COLLECTION_IDENTIFIER.test(name)
|
|
4823
|
+
)
|
|
4824
|
+
: undefined;
|
|
4825
|
+
const nextDocScope = scopeCollections
|
|
4826
|
+
? scopeCollections.length === 0
|
|
4827
|
+
? " AND 0"
|
|
4828
|
+
: ` AND next_doc.collection IN (${scopeCollections
|
|
4829
|
+
.map((name) => `'${name}'`)
|
|
4830
|
+
.join(",")})`
|
|
4831
|
+
: "";
|
|
4832
|
+
|
|
4760
4833
|
const nodeLimit = Math.min(maxNodes, visitedLimit);
|
|
4761
4834
|
const edgeTypeFilter = edgeType ? "AND e.edge_type = ?" : "";
|
|
4762
4835
|
const edgeTypeFilterFor = (alias: string): string =>
|
|
@@ -4795,7 +4868,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4795
4868
|
ORDER BY e2.edge_type ASC, e2.id ASC
|
|
4796
4869
|
) AS next_rank
|
|
4797
4870
|
FROM doc_edges e2
|
|
4798
|
-
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
|
|
4871
|
+
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4799
4872
|
WHERE e2.src_doc_id = ${frontierName}.doc_id
|
|
4800
4873
|
${edgeTypeFilterFor("e2")}
|
|
4801
4874
|
AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
|
|
@@ -4826,7 +4899,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4826
4899
|
ORDER BY e2.edge_type ASC, e2.id ASC
|
|
4827
4900
|
) AS next_rank
|
|
4828
4901
|
FROM doc_edges e2
|
|
4829
|
-
JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1
|
|
4902
|
+
JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4830
4903
|
WHERE e2.dst_doc_id = ${frontierName}.doc_id
|
|
4831
4904
|
${edgeTypeFilterFor("e2")}
|
|
4832
4905
|
AND instr(${frontierName}.path, printf(',%d,', e2.src_doc_id)) = 0
|
|
@@ -4857,7 +4930,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4857
4930
|
ORDER BY e2.edge_type ASC, e2.id ASC
|
|
4858
4931
|
) AS next_rank
|
|
4859
4932
|
FROM doc_edges e2
|
|
4860
|
-
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
|
|
4933
|
+
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4861
4934
|
WHERE e2.src_doc_id = ${frontierName}.doc_id
|
|
4862
4935
|
${edgeTypeFilterFor("e2")}
|
|
4863
4936
|
AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
|
|
@@ -4885,7 +4958,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4885
4958
|
ORDER BY e3.edge_type ASC, e3.id ASC
|
|
4886
4959
|
) AS next_rank
|
|
4887
4960
|
FROM doc_edges e3
|
|
4888
|
-
JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1
|
|
4961
|
+
JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4889
4962
|
WHERE e3.dst_doc_id = ${frontierName}.doc_id
|
|
4890
4963
|
${edgeTypeFilterFor("e3")}
|
|
4891
4964
|
AND instr(${frontierName}.path, printf(',%d,', e3.src_doc_id)) = 0
|
|
@@ -5255,19 +5328,53 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5255
5328
|
: "";
|
|
5256
5329
|
const params = sourceIds ? [JSON.stringify(sourceIds)] : [];
|
|
5257
5330
|
const inserted = db.transaction(() => {
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5331
|
+
// Wiki edges use the shared (workspace-aware) resolver in batches;
|
|
5332
|
+
// tied workspace links are audit-only and never become edges.
|
|
5333
|
+
const wikiRows = db
|
|
5334
|
+
.query<
|
|
5335
|
+
{
|
|
5336
|
+
source_id: number;
|
|
5337
|
+
source_collection: string;
|
|
5338
|
+
source_rel_path: string;
|
|
5339
|
+
target_ref_norm: string;
|
|
5340
|
+
target_collection: string | null;
|
|
5341
|
+
},
|
|
5342
|
+
string[]
|
|
5343
|
+
>(`
|
|
5344
|
+
SELECT src.id AS source_id, src.collection AS source_collection,
|
|
5345
|
+
src.rel_path AS source_rel_path, dl.target_ref_norm,
|
|
5346
|
+
dl.target_collection
|
|
5262
5347
|
FROM documents src JOIN doc_links dl ON dl.source_doc_id = src.id
|
|
5263
|
-
|
|
5264
|
-
"COALESCE(dl.target_collection, src.collection)",
|
|
5265
|
-
"dl.target_ref_norm"
|
|
5266
|
-
)})
|
|
5267
|
-
WHERE src.active = 1 AND tgt.active = 1 AND dl.link_type = 'wiki'
|
|
5348
|
+
WHERE src.active = 1 AND dl.link_type = 'wiki'
|
|
5268
5349
|
${sourceFilter}
|
|
5350
|
+
ORDER BY src.id, dl.id
|
|
5269
5351
|
`)
|
|
5270
5352
|
.all(...params);
|
|
5353
|
+
const wikiResolutions = resolveGraphLinkTargets(
|
|
5354
|
+
db,
|
|
5355
|
+
wikiRows.map((row) => ({
|
|
5356
|
+
targetRefNorm: row.target_ref_norm,
|
|
5357
|
+
targetCollection: row.target_collection ?? row.source_collection,
|
|
5358
|
+
linkType: "wiki" as const,
|
|
5359
|
+
source: linkSourceIdentity(row),
|
|
5360
|
+
}))
|
|
5361
|
+
);
|
|
5362
|
+
const wikiKeys = new Set<string>();
|
|
5363
|
+
const wiki: DesiredGraphEdge[] = [];
|
|
5364
|
+
for (const [index, row] of wikiRows.entries()) {
|
|
5365
|
+
const resolution = wikiResolutions[index];
|
|
5366
|
+
if (!isTraversableResolution(resolution)) continue;
|
|
5367
|
+
const key = `${row.source_id}:${resolution.targetId}`;
|
|
5368
|
+
if (wikiKeys.has(key)) continue;
|
|
5369
|
+
wikiKeys.add(key);
|
|
5370
|
+
wiki.push({
|
|
5371
|
+
sourceId: row.source_id,
|
|
5372
|
+
targetId: resolution.targetId,
|
|
5373
|
+
edgeType: "mentions",
|
|
5374
|
+
confidence: "parsed",
|
|
5375
|
+
source: "wikilink",
|
|
5376
|
+
});
|
|
5377
|
+
}
|
|
5271
5378
|
const markdown = db
|
|
5272
5379
|
.query<DesiredGraphEdge, string[]>(`
|
|
5273
5380
|
SELECT DISTINCT src.id AS sourceId, tgt.id AS targetId,
|
|
@@ -5362,12 +5469,14 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5362
5469
|
link_type: "wiki" | "markdown";
|
|
5363
5470
|
match_rank: number | null;
|
|
5364
5471
|
match_count: number | null;
|
|
5472
|
+
reason?: string;
|
|
5365
5473
|
}
|
|
5366
5474
|
|
|
5367
5475
|
interface GraphLinkResolutionRow {
|
|
5368
5476
|
source_id: number;
|
|
5369
5477
|
source_docid: string;
|
|
5370
5478
|
source_collection: string;
|
|
5479
|
+
source_rel_path: string;
|
|
5371
5480
|
target_ref_norm: string;
|
|
5372
5481
|
target_collection: string | null;
|
|
5373
5482
|
link_type: "wiki" | "markdown";
|
|
@@ -5396,6 +5505,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5396
5505
|
src.id as source_id,
|
|
5397
5506
|
src.docid as source_docid,
|
|
5398
5507
|
src.collection as source_collection,
|
|
5508
|
+
src.rel_path as source_rel_path,
|
|
5399
5509
|
dl.target_ref_norm,
|
|
5400
5510
|
dl.target_collection,
|
|
5401
5511
|
dl.link_type
|
|
@@ -5413,6 +5523,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5413
5523
|
targetRefNorm: row.target_ref_norm,
|
|
5414
5524
|
targetCollection: row.target_collection ?? row.source_collection,
|
|
5415
5525
|
linkType: row.link_type,
|
|
5526
|
+
source: linkSourceIdentity(row),
|
|
5416
5527
|
}))
|
|
5417
5528
|
);
|
|
5418
5529
|
const resolvedEdgeRows: ResolvedEdgeRow[] = [];
|
|
@@ -5422,11 +5533,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5422
5533
|
};
|
|
5423
5534
|
for (const [index, row] of graphLinkRows.entries()) {
|
|
5424
5535
|
const resolution = resolutions[index];
|
|
5425
|
-
if (!resolution) {
|
|
5536
|
+
if (!isTraversableResolution(resolution)) {
|
|
5426
5537
|
unresolvedByType[row.link_type] += 1;
|
|
5427
5538
|
continue;
|
|
5428
5539
|
}
|
|
5429
|
-
|
|
5540
|
+
// Scope uses the resolved target identity, not the declared prefix.
|
|
5541
|
+
const targetCollection =
|
|
5542
|
+
resolution.targetCollection ??
|
|
5543
|
+
row.target_collection ??
|
|
5544
|
+
row.source_collection;
|
|
5430
5545
|
if (collection && targetCollection !== collection) continue;
|
|
5431
5546
|
resolvedEdgeRows.push({
|
|
5432
5547
|
source_id: row.source_id,
|
|
@@ -5436,6 +5551,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5436
5551
|
link_type: row.link_type,
|
|
5437
5552
|
match_rank: resolution.matchRank,
|
|
5438
5553
|
match_count: resolution.matchCount,
|
|
5554
|
+
reason: resolution.reason,
|
|
5439
5555
|
});
|
|
5440
5556
|
}
|
|
5441
5557
|
resolvedEdgeRows.sort(
|
|
@@ -5617,7 +5733,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5617
5733
|
const { confidence, audit } = classifyResolvedGraphEdge(
|
|
5618
5734
|
row.link_type,
|
|
5619
5735
|
row.match_rank,
|
|
5620
|
-
row.match_count
|
|
5736
|
+
row.match_count,
|
|
5737
|
+
row.reason
|
|
5621
5738
|
);
|
|
5622
5739
|
const existing = edgeMap.get(key);
|
|
5623
5740
|
if (existing) {
|
|
@@ -5927,6 +6044,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5927
6044
|
path: string;
|
|
5928
6045
|
egress_policy: EgressPolicy;
|
|
5929
6046
|
egress_policy_source: EgressPolicySource;
|
|
6047
|
+
workspace_root: string | null;
|
|
6048
|
+
workspace_source: LinkWorkspaceSource | null;
|
|
5930
6049
|
total: number;
|
|
5931
6050
|
active: number;
|
|
5932
6051
|
errored: number;
|
|
@@ -5980,6 +6099,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5980
6099
|
c.path,
|
|
5981
6100
|
c.egress_policy,
|
|
5982
6101
|
c.egress_policy_source,
|
|
6102
|
+
c.workspace_root,
|
|
6103
|
+
c.workspace_source,
|
|
5983
6104
|
COALESCE(ds.total, 0) AS total,
|
|
5984
6105
|
COALESCE(ds.active, 0) AS active,
|
|
5985
6106
|
COALESCE(ds.errored, 0) AS errored,
|
|
@@ -6081,6 +6202,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6081
6202
|
path: s.path,
|
|
6082
6203
|
egressPolicy: s.egress_policy,
|
|
6083
6204
|
egressPolicySource: s.egress_policy_source,
|
|
6205
|
+
workspaceRoot: s.workspace_root,
|
|
6206
|
+
workspaceSource: s.workspace_source ?? "none",
|
|
6084
6207
|
totalDocuments: s.total,
|
|
6085
6208
|
activeDocuments: s.active,
|
|
6086
6209
|
errorDocuments: s.errored,
|
|
@@ -6357,6 +6480,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6357
6480
|
}
|
|
6358
6481
|
}
|
|
6359
6482
|
|
|
6483
|
+
/** Collection names as the config schema admits them (safe to inline). */
|
|
6484
|
+
const COLLECTION_IDENTIFIER = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
6485
|
+
|
|
6486
|
+
/**
|
|
6487
|
+
* Workspace columns on collection upsert. Nested vault prefixes are refreshed
|
|
6488
|
+
* by ingestion; they stay valid only while the collection's real root does.
|
|
6489
|
+
*/
|
|
6490
|
+
const COLLECTION_WORKSPACE_UPSERT_SET = `real_path = excluded.real_path,
|
|
6491
|
+
workspace_root = excluded.workspace_root,
|
|
6492
|
+
workspace_source = excluded.workspace_source,
|
|
6493
|
+
workspace_nested = CASE
|
|
6494
|
+
WHEN collections.real_path IS excluded.real_path
|
|
6495
|
+
THEN collections.workspace_nested
|
|
6496
|
+
ELSE NULL
|
|
6497
|
+
END,`;
|
|
6498
|
+
|
|
6360
6499
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
6361
6500
|
// DB Row Types (snake_case from SQLite)
|
|
6362
6501
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -6371,6 +6510,10 @@ interface DbCollectionRow {
|
|
|
6371
6510
|
language_hint: string | null;
|
|
6372
6511
|
egress_policy: EgressPolicy;
|
|
6373
6512
|
egress_policy_source: EgressPolicySource;
|
|
6513
|
+
real_path?: string | null;
|
|
6514
|
+
workspace_root?: string | null;
|
|
6515
|
+
workspace_source?: LinkWorkspaceSource | null;
|
|
6516
|
+
workspace_nested?: string | null;
|
|
6374
6517
|
synced_at: string;
|
|
6375
6518
|
}
|
|
6376
6519
|
|
|
@@ -6462,6 +6605,18 @@ interface DbIngestErrorRow {
|
|
|
6462
6605
|
// Row Mappers (snake_case -> camelCase)
|
|
6463
6606
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
6464
6607
|
|
|
6608
|
+
function parseWorkspaceNested(raw: string | null): string[] {
|
|
6609
|
+
if (!raw) return [];
|
|
6610
|
+
try {
|
|
6611
|
+
const parsed: unknown = JSON.parse(raw);
|
|
6612
|
+
return Array.isArray(parsed)
|
|
6613
|
+
? parsed.filter((value): value is string => typeof value === "string")
|
|
6614
|
+
: [];
|
|
6615
|
+
} catch {
|
|
6616
|
+
return [];
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
6619
|
+
|
|
6465
6620
|
function mapCollectionRow(row: DbCollectionRow): CollectionRow {
|
|
6466
6621
|
return {
|
|
6467
6622
|
name: row.name,
|
|
@@ -6473,6 +6628,10 @@ function mapCollectionRow(row: DbCollectionRow): CollectionRow {
|
|
|
6473
6628
|
languageHint: row.language_hint,
|
|
6474
6629
|
egressPolicy: row.egress_policy,
|
|
6475
6630
|
egressPolicySource: row.egress_policy_source,
|
|
6631
|
+
realPath: row.real_path ?? null,
|
|
6632
|
+
workspaceRoot: row.workspace_root ?? null,
|
|
6633
|
+
workspaceSource: row.workspace_source ?? "none",
|
|
6634
|
+
workspaceNested: parseWorkspaceNested(row.workspace_nested ?? null),
|
|
6476
6635
|
syncedAt: row.synced_at,
|
|
6477
6636
|
};
|
|
6478
6637
|
}
|