@gmickel/gno 2.7.1 → 2.8.1
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 +11 -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/skill/recipes/memory-scoped-recall.md +10 -5
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +86 -9
- package/spec/db/schema.sql +0 -1
- package/spec/mcp.md +26 -7
- 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/memory-recall.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +18 -3
- 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/graph.ts +3 -1
- package/src/cli/commands/links.ts +61 -180
- package/src/cli/commands/shared.ts +7 -0
- package/src/cli/commands/status.ts +6 -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 +197 -25
- package/src/core/audit-outside-index.ts +215 -0
- package/src/core/audit-provenance.ts +11 -4
- package/src/core/audit-workspace.ts +30 -9
- package/src/core/audit.ts +76 -16
- 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-inventory-markdown.ts +2 -3
- package/src/core/link-workspace.ts +324 -0
- package/src/core/links.ts +40 -17
- package/src/core/memory-recall.ts +254 -15
- package/src/core/memory-types.ts +12 -0
- package/src/core/memory.ts +2 -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 +27 -4
- 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 +74 -93
- package/src/mcp/tools/sessions.ts +33 -4
- package/src/mcp/tools/status.ts +4 -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/search.ts +2 -0
- package/src/pipeline/types.ts +10 -3
- package/src/sdk/client.ts +1 -0
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/graph.ts +3 -1
- package/src/serve/routes/links.ts +45 -50
- package/src/serve/routes/sessions.ts +41 -53
- package/src/serve/server.ts +2 -1
- package/src/serve/status.ts +1 -0
- 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 +477 -337
- package/src/store/sqlite/eligibility.ts +8 -2
- package/src/store/sqlite/graph-link-resolver.ts +259 -5
- package/src/store/sqlite/graph-neighbors.ts +147 -40
- package/src/store/sqlite/graph-reference-state.ts +13 -2
- package/src/store/sqlite/graph-similarity.ts +96 -0
- package/src/store/sqlite/workspace-link-resolver.ts +742 -0
- package/src/store/types.ts +64 -5
- package/src/store/vector/stats.ts +1 -1
- package/src/store/vector/status.ts +27 -0
- package/src/store/vector/stored-vectors.ts +158 -0
- package/src/store/vector/types.ts +6 -0
- package/src/store/vector/variant-search.ts +30 -14
- 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,
|
|
@@ -163,6 +167,7 @@ import {
|
|
|
163
167
|
listVectorPartitions,
|
|
164
168
|
vectorRuntimeStatus,
|
|
165
169
|
} from "../vector/status";
|
|
170
|
+
import { loadSqliteVec } from "../vector/variants";
|
|
166
171
|
import {
|
|
167
172
|
deleteSavedCapsuleRegistration as deleteStoredSavedCapsuleRegistration,
|
|
168
173
|
getSavedCapsuleRegistration as getStoredSavedCapsuleRegistration,
|
|
@@ -213,9 +218,15 @@ import {
|
|
|
213
218
|
applyGraphEdges,
|
|
214
219
|
type DesiredGraphEdge,
|
|
215
220
|
} from "./graph-edge-application";
|
|
216
|
-
import {
|
|
221
|
+
import {
|
|
222
|
+
type GraphLinkSourceIdentity,
|
|
223
|
+
isTraversableResolution,
|
|
224
|
+
linkSourceIdentity,
|
|
225
|
+
resolveGraphLinkTargets,
|
|
226
|
+
} from "./graph-link-resolver";
|
|
217
227
|
import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
|
|
218
228
|
import { createGraphReferenceStore } from "./graph-reference-state";
|
|
229
|
+
import { hasSqliteVec, storedSimilarityEdges } from "./graph-similarity";
|
|
219
230
|
import {
|
|
220
231
|
snapshotLegacyTitles,
|
|
221
232
|
reconcileLegacyTitles,
|
|
@@ -243,6 +254,11 @@ import {
|
|
|
243
254
|
listTraces as listStoredTraces,
|
|
244
255
|
mergeTraceEgressLineage as mergeStoredTraceEgressLineage,
|
|
245
256
|
} from "./retrieval-trace-store";
|
|
257
|
+
import {
|
|
258
|
+
loadLinkWorkspaceMemberships,
|
|
259
|
+
workspaceKeyForDocument,
|
|
260
|
+
workspaceMemberCollections,
|
|
261
|
+
} from "./workspace-link-resolver";
|
|
246
262
|
|
|
247
263
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
264
|
// FTS5 Query Escaping
|
|
@@ -903,11 +919,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
903
919
|
INSERT INTO collections (
|
|
904
920
|
name, path, pattern, include, exclude, update_cmd, language_hint,
|
|
905
921
|
egress_policy, egress_policy_source, egress_policy_revision,
|
|
922
|
+
real_path, workspace_root, workspace_source,
|
|
906
923
|
synced_at
|
|
907
924
|
)
|
|
908
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
925
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
909
926
|
ON CONFLICT(name) DO UPDATE SET
|
|
910
927
|
path = excluded.path,
|
|
928
|
+
${COLLECTION_WORKSPACE_UPSERT_SET}
|
|
911
929
|
pattern = excluded.pattern,
|
|
912
930
|
include = excluded.include,
|
|
913
931
|
exclude = excluded.exclude,
|
|
@@ -936,6 +954,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
936
954
|
|
|
937
955
|
for (const c of collections) {
|
|
938
956
|
const egress = resolveConfiguredEgressPolicy(c);
|
|
957
|
+
const workspace = detectCollectionWorkspace(c);
|
|
939
958
|
stmt.run(
|
|
940
959
|
c.name,
|
|
941
960
|
c.path,
|
|
@@ -946,7 +965,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
946
965
|
c.languageHint ?? null,
|
|
947
966
|
egress.policy,
|
|
948
967
|
egress.source,
|
|
949
|
-
c.egressPolicyRevision ?? 0
|
|
968
|
+
c.egressPolicyRevision ?? 0,
|
|
969
|
+
workspace.realPath,
|
|
970
|
+
workspace.root,
|
|
971
|
+
workspace.source
|
|
950
972
|
);
|
|
951
973
|
}
|
|
952
974
|
});
|
|
@@ -971,11 +993,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
971
993
|
INSERT INTO collections (
|
|
972
994
|
name, path, pattern, include, exclude, update_cmd, language_hint,
|
|
973
995
|
egress_policy, egress_policy_source, egress_policy_revision,
|
|
996
|
+
real_path, workspace_root, workspace_source,
|
|
974
997
|
synced_at
|
|
975
998
|
)
|
|
976
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
999
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
977
1000
|
ON CONFLICT(name) DO UPDATE SET
|
|
978
1001
|
path = excluded.path,
|
|
1002
|
+
${COLLECTION_WORKSPACE_UPSERT_SET}
|
|
979
1003
|
pattern = excluded.pattern,
|
|
980
1004
|
include = excluded.include,
|
|
981
1005
|
exclude = excluded.exclude,
|
|
@@ -1001,6 +1025,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1001
1025
|
END,
|
|
1002
1026
|
synced_at = datetime('now')
|
|
1003
1027
|
WHERE collections.path IS NOT excluded.path
|
|
1028
|
+
OR collections.real_path IS NOT excluded.real_path
|
|
1029
|
+
OR collections.workspace_root IS NOT excluded.workspace_root
|
|
1030
|
+
OR collections.workspace_source IS NOT excluded.workspace_source
|
|
1004
1031
|
OR collections.pattern IS NOT excluded.pattern
|
|
1005
1032
|
OR collections.include IS NOT excluded.include
|
|
1006
1033
|
OR collections.exclude IS NOT excluded.exclude
|
|
@@ -1021,6 +1048,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1021
1048
|
const transaction = db.transaction(() => {
|
|
1022
1049
|
for (const collection of collections) {
|
|
1023
1050
|
const egress = resolveConfiguredEgressPolicy(collection);
|
|
1051
|
+
const workspace = detectCollectionWorkspace(collection);
|
|
1024
1052
|
stmt.run(
|
|
1025
1053
|
collection.name,
|
|
1026
1054
|
collection.path,
|
|
@@ -1035,7 +1063,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1035
1063
|
collection.languageHint ?? null,
|
|
1036
1064
|
egress.policy,
|
|
1037
1065
|
egress.source,
|
|
1038
|
-
collection.egressPolicyRevision ?? 0
|
|
1066
|
+
collection.egressPolicyRevision ?? 0,
|
|
1067
|
+
workspace.realPath,
|
|
1068
|
+
workspace.root,
|
|
1069
|
+
workspace.source
|
|
1039
1070
|
);
|
|
1040
1071
|
}
|
|
1041
1072
|
});
|
|
@@ -1129,6 +1160,62 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1129
1160
|
}
|
|
1130
1161
|
}
|
|
1131
1162
|
|
|
1163
|
+
/**
|
|
1164
|
+
* Re-detect nested vaults (`.obsidian/` below the collection root) from the
|
|
1165
|
+
* directories of the collection's active documents. Returns whether the
|
|
1166
|
+
* stored membership changed.
|
|
1167
|
+
*/
|
|
1168
|
+
async refreshCollectionNestedWorkspaces(
|
|
1169
|
+
collection: string
|
|
1170
|
+
): Promise<StoreResult<boolean>> {
|
|
1171
|
+
try {
|
|
1172
|
+
const db = this.ensureOpen();
|
|
1173
|
+
const row = db
|
|
1174
|
+
.query<
|
|
1175
|
+
{
|
|
1176
|
+
real_path: string | null;
|
|
1177
|
+
workspace_source: LinkWorkspaceSource | null;
|
|
1178
|
+
workspace_nested: string | null;
|
|
1179
|
+
},
|
|
1180
|
+
[string]
|
|
1181
|
+
>(
|
|
1182
|
+
"SELECT real_path, workspace_source, workspace_nested FROM collections WHERE name = ?"
|
|
1183
|
+
)
|
|
1184
|
+
.get(collection);
|
|
1185
|
+
if (!row) return ok(false);
|
|
1186
|
+
const eligible =
|
|
1187
|
+
row.real_path !== null &&
|
|
1188
|
+
row.workspace_source !== "disabled" &&
|
|
1189
|
+
row.workspace_source !== "unavailable";
|
|
1190
|
+
const nested = eligible
|
|
1191
|
+
? detectNestedWorkspacePrefixes(
|
|
1192
|
+
row.real_path as string,
|
|
1193
|
+
db
|
|
1194
|
+
.query<{ rel_path: string }, [string]>(
|
|
1195
|
+
"SELECT rel_path FROM documents WHERE collection = ? AND active = 1"
|
|
1196
|
+
)
|
|
1197
|
+
.all(collection)
|
|
1198
|
+
.map((document) => document.rel_path)
|
|
1199
|
+
)
|
|
1200
|
+
: [];
|
|
1201
|
+
const next = nested.length > 0 ? JSON.stringify(nested) : null;
|
|
1202
|
+
if (next === row.workspace_nested) return ok(false);
|
|
1203
|
+
db.run("UPDATE collections SET workspace_nested = ? WHERE name = ?", [
|
|
1204
|
+
next,
|
|
1205
|
+
collection,
|
|
1206
|
+
]);
|
|
1207
|
+
return ok(true);
|
|
1208
|
+
} catch (cause) {
|
|
1209
|
+
return err(
|
|
1210
|
+
"QUERY_FAILED",
|
|
1211
|
+
cause instanceof Error
|
|
1212
|
+
? cause.message
|
|
1213
|
+
: "Failed to refresh nested link workspaces",
|
|
1214
|
+
cause
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1132
1219
|
async getContexts(): Promise<StoreResult<ContextRow[]>> {
|
|
1133
1220
|
try {
|
|
1134
1221
|
const db = this.ensureOpen();
|
|
@@ -2183,7 +2270,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2183
2270
|
params.push(options.collection);
|
|
2184
2271
|
}
|
|
2185
2272
|
|
|
2186
|
-
const sql = `SELECT * FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id`;
|
|
2273
|
+
const sql = `SELECT * FROM documents INDEXED BY idx_documents_mirror_hash WHERE ${clauses.join(" AND ")} ORDER BY id`;
|
|
2187
2274
|
rows.push(...db.query<DbDocumentRow, string[]>(sql).all(...params));
|
|
2188
2275
|
}
|
|
2189
2276
|
|
|
@@ -3133,9 +3220,18 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3133
3220
|
...(options.chunkLanguage ? [options.chunkLanguage] : []),
|
|
3134
3221
|
limit,
|
|
3135
3222
|
];
|
|
3136
|
-
const
|
|
3223
|
+
const allRows = db
|
|
3137
3224
|
.query<FtsRow, (string | number)[]>(sql)
|
|
3138
3225
|
.all(...queryParams);
|
|
3226
|
+
// Raw bm25() is negative and rows are best-first.
|
|
3227
|
+
const floor =
|
|
3228
|
+
options.minRelativeScore !== undefined && allRows[0]
|
|
3229
|
+
? allRows[0].score * options.minRelativeScore
|
|
3230
|
+
: undefined;
|
|
3231
|
+
const rows =
|
|
3232
|
+
floor === undefined
|
|
3233
|
+
? allRows
|
|
3234
|
+
: allRows.filter((row) => row.score <= floor);
|
|
3139
3235
|
|
|
3140
3236
|
return ok(
|
|
3141
3237
|
rows.map((r) => ({
|
|
@@ -3786,12 +3882,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3786
3882
|
|
|
3787
3883
|
/**
|
|
3788
3884
|
* Get backlinks pointing to a document.
|
|
3789
|
-
*
|
|
3885
|
+
* Collection-scoped links match target_ref_norm (wiki=normalized title with
|
|
3886
|
+
* path fallbacks, markdown=rel_path). Plain wiki links from a document in a
|
|
3887
|
+
* link workspace count only when the shared resolver lands them on this
|
|
3888
|
+
* document (tied links never count). `collection` / `collections` restrict
|
|
3889
|
+
* the SOURCE collections; unset means every collection.
|
|
3790
3890
|
* Only returns links from active source documents.
|
|
3791
3891
|
*/
|
|
3792
3892
|
async getBacklinksForDoc(
|
|
3793
3893
|
documentId: number,
|
|
3794
|
-
options?: { collection?: string }
|
|
3894
|
+
options?: { collection?: string; collections?: string[] }
|
|
3795
3895
|
): Promise<StoreResult<BacklinkRow[]>> {
|
|
3796
3896
|
try {
|
|
3797
3897
|
const db = this.ensureOpen();
|
|
@@ -3841,17 +3941,34 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3841
3941
|
addVariantsWithBasename(relPathKey);
|
|
3842
3942
|
|
|
3843
3943
|
interface DbBacklinkRow {
|
|
3944
|
+
link_id: number;
|
|
3844
3945
|
source_doc_id: number;
|
|
3845
3946
|
docid: string;
|
|
3846
3947
|
uri: string;
|
|
3847
3948
|
title: string | null;
|
|
3949
|
+
source_collection: string;
|
|
3950
|
+
source_rel_path: string;
|
|
3951
|
+
target_ref_norm: string;
|
|
3952
|
+
target_collection: string | null;
|
|
3848
3953
|
link_text: string | null;
|
|
3849
3954
|
start_line: number;
|
|
3850
3955
|
start_col: number;
|
|
3851
3956
|
}
|
|
3852
3957
|
|
|
3853
3958
|
const targetCollection = target.collection;
|
|
3854
|
-
const
|
|
3959
|
+
const sourceAllowlist =
|
|
3960
|
+
options?.collections ??
|
|
3961
|
+
(options?.collection ? [options.collection] : undefined);
|
|
3962
|
+
const sourceScopeSql = sourceAllowlist
|
|
3963
|
+
? sourceAllowlist.length === 0
|
|
3964
|
+
? "AND 0"
|
|
3965
|
+
: `AND src.collection IN (${sourceAllowlist.map(() => "?").join(",")})`
|
|
3966
|
+
: "";
|
|
3967
|
+
const sourceScopeParams = sourceAllowlist ?? [];
|
|
3968
|
+
const backlinkColumns = `dl.id AS link_id, dl.source_doc_id, src.docid,
|
|
3969
|
+
src.uri, src.title, src.collection AS source_collection,
|
|
3970
|
+
src.rel_path AS source_rel_path, dl.target_ref_norm,
|
|
3971
|
+
dl.target_collection, dl.link_text, dl.start_line, dl.start_col`;
|
|
3855
3972
|
|
|
3856
3973
|
// Query wiki backlinks (link_type='wiki') with path-style fallbacks
|
|
3857
3974
|
// NULL target_collection means "same collection as source" - enforce this in SQL
|
|
@@ -3881,7 +3998,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3881
3998
|
wikiConditions.length > 0
|
|
3882
3999
|
? db
|
|
3883
4000
|
.query<DbBacklinkRow, string[]>(
|
|
3884
|
-
`SELECT
|
|
4001
|
+
`SELECT ${backlinkColumns}
|
|
3885
4002
|
FROM doc_links dl
|
|
3886
4003
|
JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
|
|
3887
4004
|
WHERE dl.link_type = 'wiki'
|
|
@@ -3890,22 +4007,112 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3890
4007
|
(dl.target_collection IS NULL AND src.collection = ?)
|
|
3891
4008
|
OR dl.target_collection = ?
|
|
3892
4009
|
)
|
|
3893
|
-
${
|
|
4010
|
+
${sourceScopeSql}
|
|
3894
4011
|
ORDER BY src.uri, dl.start_line, dl.start_col`
|
|
3895
4012
|
)
|
|
3896
4013
|
.all(
|
|
3897
4014
|
...wikiParams,
|
|
3898
4015
|
targetCollection,
|
|
3899
4016
|
targetCollection,
|
|
3900
|
-
...
|
|
4017
|
+
...sourceScopeParams
|
|
3901
4018
|
)
|
|
3902
4019
|
: [];
|
|
3903
4020
|
|
|
4021
|
+
// Workspace candidates: plain links from any member collection whose
|
|
4022
|
+
// last segment names this file. Resolution decides which count.
|
|
4023
|
+
const memberships = loadLinkWorkspaceMemberships(db);
|
|
4024
|
+
const targetWsKey = workspaceKeyForDocument(
|
|
4025
|
+
memberships,
|
|
4026
|
+
target.collection,
|
|
4027
|
+
target.rel_path
|
|
4028
|
+
);
|
|
4029
|
+
const workspaceBacklinks: DbBacklinkRow[] = [];
|
|
4030
|
+
if (targetWsKey !== null) {
|
|
4031
|
+
const members = workspaceMemberCollections(
|
|
4032
|
+
memberships,
|
|
4033
|
+
new Set([targetWsKey])
|
|
4034
|
+
);
|
|
4035
|
+
const base = stripWikiMdExt(
|
|
4036
|
+
normalizeWikiName(target.rel_path.split("/").pop() ?? target.rel_path)
|
|
4037
|
+
);
|
|
4038
|
+
const escapeLike = (value: string): string =>
|
|
4039
|
+
value
|
|
4040
|
+
.replaceAll("\\", "\\\\")
|
|
4041
|
+
.replaceAll("%", "\\%")
|
|
4042
|
+
.replaceAll("_", "\\_");
|
|
4043
|
+
if (members.length > 0) {
|
|
4044
|
+
workspaceBacklinks.push(
|
|
4045
|
+
...db
|
|
4046
|
+
.query<DbBacklinkRow, string[]>(
|
|
4047
|
+
`SELECT ${backlinkColumns}
|
|
4048
|
+
FROM doc_links dl
|
|
4049
|
+
JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
|
|
4050
|
+
WHERE dl.link_type = 'wiki'
|
|
4051
|
+
AND dl.target_collection IS NULL
|
|
4052
|
+
AND src.collection IN (${members.map(() => "?").join(",")})
|
|
4053
|
+
AND (dl.target_ref_norm IN (?, ?)
|
|
4054
|
+
OR dl.target_ref_norm LIKE ? ESCAPE '\\'
|
|
4055
|
+
OR dl.target_ref_norm LIKE ? ESCAPE '\\'
|
|
4056
|
+
OR dl.target_ref_norm IN (${[...keySet].map(() => "?").join(",") || "NULL"}))
|
|
4057
|
+
${sourceScopeSql}
|
|
4058
|
+
ORDER BY src.uri, dl.start_line, dl.start_col`
|
|
4059
|
+
)
|
|
4060
|
+
.all(
|
|
4061
|
+
...members,
|
|
4062
|
+
base,
|
|
4063
|
+
`${base}.md`,
|
|
4064
|
+
`%/${escapeLike(base)}`,
|
|
4065
|
+
`%/${escapeLike(`${base}.md`)}`,
|
|
4066
|
+
...keySet,
|
|
4067
|
+
...sourceScopeParams
|
|
4068
|
+
)
|
|
4069
|
+
);
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
const candidates = new Map<number, DbBacklinkRow>();
|
|
4073
|
+
for (const row of [...wikiBacklinks, ...workspaceBacklinks]) {
|
|
4074
|
+
candidates.set(row.link_id, row);
|
|
4075
|
+
}
|
|
4076
|
+
const candidateRows = [...candidates.values()];
|
|
4077
|
+
const resolutions = resolveGraphLinkTargets(
|
|
4078
|
+
db,
|
|
4079
|
+
candidateRows.map((row) => ({
|
|
4080
|
+
targetRefNorm: row.target_ref_norm,
|
|
4081
|
+
targetCollection: row.target_collection ?? row.source_collection,
|
|
4082
|
+
linkType: "wiki" as const,
|
|
4083
|
+
source: linkSourceIdentity(row),
|
|
4084
|
+
}))
|
|
4085
|
+
);
|
|
4086
|
+
const legacyLinkIds = new Set(wikiBacklinks.map((row) => row.link_id));
|
|
4087
|
+
const resolvedWikiBacklinks = candidateRows
|
|
4088
|
+
.filter((row, index) => {
|
|
4089
|
+
const workspaceLink =
|
|
4090
|
+
!row.target_collection &&
|
|
4091
|
+
workspaceKeyForDocument(
|
|
4092
|
+
memberships,
|
|
4093
|
+
row.source_collection,
|
|
4094
|
+
row.source_rel_path
|
|
4095
|
+
) !== null;
|
|
4096
|
+
// Collection-scoped links keep their key-match semantics.
|
|
4097
|
+
if (!workspaceLink) return legacyLinkIds.has(row.link_id);
|
|
4098
|
+
const resolution = resolutions[index];
|
|
4099
|
+
return (
|
|
4100
|
+
isTraversableResolution(resolution) &&
|
|
4101
|
+
resolution.targetId === documentId
|
|
4102
|
+
);
|
|
4103
|
+
})
|
|
4104
|
+
.sort(
|
|
4105
|
+
(left, right) =>
|
|
4106
|
+
(left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0) ||
|
|
4107
|
+
left.start_line - right.start_line ||
|
|
4108
|
+
left.start_col - right.start_col
|
|
4109
|
+
);
|
|
4110
|
+
|
|
3904
4111
|
// Query markdown backlinks (link_type='markdown')
|
|
3905
4112
|
// NULL target_collection means "same collection as source" - enforce this in SQL
|
|
3906
4113
|
const mdBacklinks = db
|
|
3907
4114
|
.query<DbBacklinkRow, string[]>(
|
|
3908
|
-
`SELECT
|
|
4115
|
+
`SELECT ${backlinkColumns}
|
|
3909
4116
|
FROM doc_links dl
|
|
3910
4117
|
JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
|
|
3911
4118
|
WHERE dl.link_type = 'markdown'
|
|
@@ -3914,25 +4121,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3914
4121
|
(dl.target_collection IS NULL AND src.collection = ?)
|
|
3915
4122
|
OR dl.target_collection = ?
|
|
3916
4123
|
)
|
|
3917
|
-
${
|
|
4124
|
+
${sourceScopeSql}
|
|
3918
4125
|
ORDER BY src.uri, dl.start_line, dl.start_col`
|
|
3919
4126
|
)
|
|
3920
4127
|
.all(
|
|
3921
4128
|
target.rel_path,
|
|
3922
4129
|
targetCollection,
|
|
3923
4130
|
targetCollection,
|
|
3924
|
-
...
|
|
4131
|
+
...sourceScopeParams
|
|
3925
4132
|
);
|
|
3926
4133
|
|
|
3927
|
-
const allBacklinks = [...
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
4134
|
+
const allBacklinks = [...resolvedWikiBacklinks, ...mdBacklinks].map(
|
|
4135
|
+
(r) => ({
|
|
4136
|
+
sourceDocId: r.source_doc_id,
|
|
4137
|
+
sourceDocid: r.docid,
|
|
4138
|
+
sourceDocUri: r.uri,
|
|
4139
|
+
sourceDocTitle: r.title,
|
|
4140
|
+
sourceCollection: r.source_collection,
|
|
4141
|
+
linkText: r.link_text,
|
|
4142
|
+
startLine: r.start_line,
|
|
4143
|
+
startCol: r.start_col,
|
|
4144
|
+
})
|
|
4145
|
+
);
|
|
3936
4146
|
|
|
3937
4147
|
return ok(allBacklinks);
|
|
3938
4148
|
} catch (cause) {
|
|
@@ -3951,206 +4161,64 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3951
4161
|
targetRefNorm: string;
|
|
3952
4162
|
targetCollection: string;
|
|
3953
4163
|
linkType: "wiki" | "markdown";
|
|
4164
|
+
source?: GraphLinkSourceIdentity;
|
|
3954
4165
|
}>
|
|
3955
4166
|
): Promise<
|
|
3956
4167
|
StoreResult<
|
|
3957
|
-
Array<{
|
|
3958
|
-
>
|
|
3959
|
-
> {
|
|
3960
|
-
try {
|
|
3961
|
-
const db = this.ensureOpen();
|
|
3962
|
-
|
|
3963
|
-
const results: Array<{
|
|
4168
|
+
Array<{
|
|
3964
4169
|
docid: string;
|
|
3965
4170
|
uri: string;
|
|
3966
4171
|
title: string | null;
|
|
3967
|
-
} | null> = Array.from({ length: targets.length }, () => null);
|
|
3968
|
-
|
|
3969
|
-
const wikiTargets: Array<{
|
|
3970
|
-
idx: number;
|
|
3971
|
-
collection: string;
|
|
3972
|
-
baseRef: string;
|
|
3973
|
-
baseRefMd: string;
|
|
3974
|
-
}> = [];
|
|
3975
|
-
const mdTargets: Array<{
|
|
3976
|
-
idx: number;
|
|
3977
4172
|
collection: string;
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
4173
|
+
} | null>
|
|
4174
|
+
>
|
|
4175
|
+
> {
|
|
4176
|
+
try {
|
|
4177
|
+
const db = this.ensureOpen();
|
|
4178
|
+
const resolutions = resolveGraphLinkTargets(db, targets);
|
|
4179
|
+
const ids = [
|
|
4180
|
+
...new Set(
|
|
4181
|
+
resolutions
|
|
4182
|
+
.filter(isTraversableResolution)
|
|
4183
|
+
.map((resolution) => resolution.targetId)
|
|
4184
|
+
),
|
|
4185
|
+
];
|
|
4186
|
+
const byId = new Map<
|
|
4187
|
+
number,
|
|
4188
|
+
{ docid: string; uri: string; title: string | null; collection: string }
|
|
4189
|
+
>();
|
|
4190
|
+
for (let offset = 0; offset < ids.length; offset += 900) {
|
|
4191
|
+
const batch = ids.slice(offset, offset + 900);
|
|
4192
|
+
for (const row of db
|
|
4193
|
+
.query<
|
|
4194
|
+
{
|
|
4195
|
+
id: number;
|
|
4196
|
+
docid: string;
|
|
4197
|
+
uri: string;
|
|
4198
|
+
title: string | null;
|
|
4199
|
+
collection: string;
|
|
4200
|
+
},
|
|
4201
|
+
number[]
|
|
4202
|
+
>(
|
|
4203
|
+
`SELECT id, docid, uri, title, collection FROM documents WHERE id IN (${batch.map(() => "?").join(",")})`
|
|
4204
|
+
)
|
|
4205
|
+
.all(...batch)) {
|
|
4206
|
+
byId.set(row.id, {
|
|
4207
|
+
docid: row.docid,
|
|
4208
|
+
uri: row.uri,
|
|
4209
|
+
title: row.title,
|
|
4210
|
+
collection: row.collection,
|
|
3995
4211
|
});
|
|
3996
4212
|
}
|
|
3997
4213
|
}
|
|
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);
|
|
4214
|
+
// Tied workspace links are ambiguous: reported unresolved, never guessed.
|
|
4215
|
+
return ok(
|
|
4216
|
+
resolutions.map((resolution) =>
|
|
4217
|
+
isTraversableResolution(resolution)
|
|
4218
|
+
? (byId.get(resolution.targetId) ?? null)
|
|
4219
|
+
: null
|
|
4220
|
+
)
|
|
4221
|
+
);
|
|
4154
4222
|
} catch (cause) {
|
|
4155
4223
|
return err(
|
|
4156
4224
|
"QUERY_FAILED",
|
|
@@ -4757,6 +4825,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4757
4825
|
? normalizeDocEdgeType(options.edgeType)
|
|
4758
4826
|
: undefined;
|
|
4759
4827
|
|
|
4828
|
+
// Collection allowlist: every visited node must be in scope, so a
|
|
4829
|
+
// forbidden collection can never act as a traversal bridge. Names are
|
|
4830
|
+
// validated collection identifiers, inlined as quoted literals.
|
|
4831
|
+
const scopeCollections = options.collections
|
|
4832
|
+
? [...new Set(options.collections)].filter((name) =>
|
|
4833
|
+
COLLECTION_IDENTIFIER.test(name)
|
|
4834
|
+
)
|
|
4835
|
+
: undefined;
|
|
4836
|
+
const nextDocScope = scopeCollections
|
|
4837
|
+
? scopeCollections.length === 0
|
|
4838
|
+
? " AND 0"
|
|
4839
|
+
: ` AND next_doc.collection IN (${scopeCollections
|
|
4840
|
+
.map((name) => `'${name}'`)
|
|
4841
|
+
.join(",")})`
|
|
4842
|
+
: "";
|
|
4843
|
+
|
|
4760
4844
|
const nodeLimit = Math.min(maxNodes, visitedLimit);
|
|
4761
4845
|
const edgeTypeFilter = edgeType ? "AND e.edge_type = ?" : "";
|
|
4762
4846
|
const edgeTypeFilterFor = (alias: string): string =>
|
|
@@ -4795,7 +4879,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4795
4879
|
ORDER BY e2.edge_type ASC, e2.id ASC
|
|
4796
4880
|
) AS next_rank
|
|
4797
4881
|
FROM doc_edges e2
|
|
4798
|
-
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
|
|
4882
|
+
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4799
4883
|
WHERE e2.src_doc_id = ${frontierName}.doc_id
|
|
4800
4884
|
${edgeTypeFilterFor("e2")}
|
|
4801
4885
|
AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
|
|
@@ -4826,7 +4910,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4826
4910
|
ORDER BY e2.edge_type ASC, e2.id ASC
|
|
4827
4911
|
) AS next_rank
|
|
4828
4912
|
FROM doc_edges e2
|
|
4829
|
-
JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1
|
|
4913
|
+
JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4830
4914
|
WHERE e2.dst_doc_id = ${frontierName}.doc_id
|
|
4831
4915
|
${edgeTypeFilterFor("e2")}
|
|
4832
4916
|
AND instr(${frontierName}.path, printf(',%d,', e2.src_doc_id)) = 0
|
|
@@ -4857,7 +4941,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4857
4941
|
ORDER BY e2.edge_type ASC, e2.id ASC
|
|
4858
4942
|
) AS next_rank
|
|
4859
4943
|
FROM doc_edges e2
|
|
4860
|
-
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
|
|
4944
|
+
JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4861
4945
|
WHERE e2.src_doc_id = ${frontierName}.doc_id
|
|
4862
4946
|
${edgeTypeFilterFor("e2")}
|
|
4863
4947
|
AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
|
|
@@ -4885,7 +4969,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
4885
4969
|
ORDER BY e3.edge_type ASC, e3.id ASC
|
|
4886
4970
|
) AS next_rank
|
|
4887
4971
|
FROM doc_edges e3
|
|
4888
|
-
JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1
|
|
4972
|
+
JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1${nextDocScope}
|
|
4889
4973
|
WHERE e3.dst_doc_id = ${frontierName}.doc_id
|
|
4890
4974
|
${edgeTypeFilterFor("e3")}
|
|
4891
4975
|
AND instr(${frontierName}.path, printf(',%d,', e3.src_doc_id)) = 0
|
|
@@ -5255,19 +5339,53 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5255
5339
|
: "";
|
|
5256
5340
|
const params = sourceIds ? [JSON.stringify(sourceIds)] : [];
|
|
5257
5341
|
const inserted = db.transaction(() => {
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5342
|
+
// Wiki edges use the shared (workspace-aware) resolver in batches;
|
|
5343
|
+
// tied workspace links are audit-only and never become edges.
|
|
5344
|
+
const wikiRows = db
|
|
5345
|
+
.query<
|
|
5346
|
+
{
|
|
5347
|
+
source_id: number;
|
|
5348
|
+
source_collection: string;
|
|
5349
|
+
source_rel_path: string;
|
|
5350
|
+
target_ref_norm: string;
|
|
5351
|
+
target_collection: string | null;
|
|
5352
|
+
},
|
|
5353
|
+
string[]
|
|
5354
|
+
>(`
|
|
5355
|
+
SELECT src.id AS source_id, src.collection AS source_collection,
|
|
5356
|
+
src.rel_path AS source_rel_path, dl.target_ref_norm,
|
|
5357
|
+
dl.target_collection
|
|
5262
5358
|
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'
|
|
5359
|
+
WHERE src.active = 1 AND dl.link_type = 'wiki'
|
|
5268
5360
|
${sourceFilter}
|
|
5361
|
+
ORDER BY src.id, dl.id
|
|
5269
5362
|
`)
|
|
5270
5363
|
.all(...params);
|
|
5364
|
+
const wikiResolutions = resolveGraphLinkTargets(
|
|
5365
|
+
db,
|
|
5366
|
+
wikiRows.map((row) => ({
|
|
5367
|
+
targetRefNorm: row.target_ref_norm,
|
|
5368
|
+
targetCollection: row.target_collection ?? row.source_collection,
|
|
5369
|
+
linkType: "wiki" as const,
|
|
5370
|
+
source: linkSourceIdentity(row),
|
|
5371
|
+
}))
|
|
5372
|
+
);
|
|
5373
|
+
const wikiKeys = new Set<string>();
|
|
5374
|
+
const wiki: DesiredGraphEdge[] = [];
|
|
5375
|
+
for (const [index, row] of wikiRows.entries()) {
|
|
5376
|
+
const resolution = wikiResolutions[index];
|
|
5377
|
+
if (!isTraversableResolution(resolution)) continue;
|
|
5378
|
+
const key = `${row.source_id}:${resolution.targetId}`;
|
|
5379
|
+
if (wikiKeys.has(key)) continue;
|
|
5380
|
+
wikiKeys.add(key);
|
|
5381
|
+
wiki.push({
|
|
5382
|
+
sourceId: row.source_id,
|
|
5383
|
+
targetId: resolution.targetId,
|
|
5384
|
+
edgeType: "mentions",
|
|
5385
|
+
confidence: "parsed",
|
|
5386
|
+
source: "wikilink",
|
|
5387
|
+
});
|
|
5388
|
+
}
|
|
5271
5389
|
const markdown = db
|
|
5272
5390
|
.query<DesiredGraphEdge, string[]>(`
|
|
5273
5391
|
SELECT DISTINCT src.id AS sourceId, tgt.id AS targetId,
|
|
@@ -5345,13 +5463,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5345
5463
|
|
|
5346
5464
|
const warnings: string[] = [];
|
|
5347
5465
|
|
|
5348
|
-
// Always
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
similarAvailable =
|
|
5353
|
-
} catch {
|
|
5354
|
-
// sqlite-vec not loaded
|
|
5466
|
+
// Always report sqlite-vec availability (not just when similarity
|
|
5467
|
+
// requested); this connection loads it on first use.
|
|
5468
|
+
let similarAvailable = hasSqliteVec(db);
|
|
5469
|
+
if (!similarAvailable && (await loadSqliteVec(db))) {
|
|
5470
|
+
similarAvailable = hasSqliteVec(db);
|
|
5355
5471
|
}
|
|
5356
5472
|
|
|
5357
5473
|
interface ResolvedEdgeRow {
|
|
@@ -5362,12 +5478,14 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5362
5478
|
link_type: "wiki" | "markdown";
|
|
5363
5479
|
match_rank: number | null;
|
|
5364
5480
|
match_count: number | null;
|
|
5481
|
+
reason?: string;
|
|
5365
5482
|
}
|
|
5366
5483
|
|
|
5367
5484
|
interface GraphLinkResolutionRow {
|
|
5368
5485
|
source_id: number;
|
|
5369
5486
|
source_docid: string;
|
|
5370
5487
|
source_collection: string;
|
|
5488
|
+
source_rel_path: string;
|
|
5371
5489
|
target_ref_norm: string;
|
|
5372
5490
|
target_collection: string | null;
|
|
5373
5491
|
link_type: "wiki" | "markdown";
|
|
@@ -5396,6 +5514,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5396
5514
|
src.id as source_id,
|
|
5397
5515
|
src.docid as source_docid,
|
|
5398
5516
|
src.collection as source_collection,
|
|
5517
|
+
src.rel_path as source_rel_path,
|
|
5399
5518
|
dl.target_ref_norm,
|
|
5400
5519
|
dl.target_collection,
|
|
5401
5520
|
dl.link_type
|
|
@@ -5413,6 +5532,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5413
5532
|
targetRefNorm: row.target_ref_norm,
|
|
5414
5533
|
targetCollection: row.target_collection ?? row.source_collection,
|
|
5415
5534
|
linkType: row.link_type,
|
|
5535
|
+
source: linkSourceIdentity(row),
|
|
5416
5536
|
}))
|
|
5417
5537
|
);
|
|
5418
5538
|
const resolvedEdgeRows: ResolvedEdgeRow[] = [];
|
|
@@ -5422,11 +5542,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5422
5542
|
};
|
|
5423
5543
|
for (const [index, row] of graphLinkRows.entries()) {
|
|
5424
5544
|
const resolution = resolutions[index];
|
|
5425
|
-
if (!resolution) {
|
|
5545
|
+
if (!isTraversableResolution(resolution)) {
|
|
5426
5546
|
unresolvedByType[row.link_type] += 1;
|
|
5427
5547
|
continue;
|
|
5428
5548
|
}
|
|
5429
|
-
|
|
5549
|
+
// Scope uses the resolved target identity, not the declared prefix.
|
|
5550
|
+
const targetCollection =
|
|
5551
|
+
resolution.targetCollection ??
|
|
5552
|
+
row.target_collection ??
|
|
5553
|
+
row.source_collection;
|
|
5430
5554
|
if (collection && targetCollection !== collection) continue;
|
|
5431
5555
|
resolvedEdgeRows.push({
|
|
5432
5556
|
source_id: row.source_id,
|
|
@@ -5436,6 +5560,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5436
5560
|
link_type: row.link_type,
|
|
5437
5561
|
match_rank: resolution.matchRank,
|
|
5438
5562
|
match_count: resolution.matchCount,
|
|
5563
|
+
reason: resolution.reason,
|
|
5439
5564
|
});
|
|
5440
5565
|
}
|
|
5441
5566
|
resolvedEdgeRows.sort(
|
|
@@ -5617,7 +5742,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5617
5742
|
const { confidence, audit } = classifyResolvedGraphEdge(
|
|
5618
5743
|
row.link_type,
|
|
5619
5744
|
row.match_rank,
|
|
5620
|
-
row.match_count
|
|
5745
|
+
row.match_count,
|
|
5746
|
+
row.reason
|
|
5621
5747
|
);
|
|
5622
5748
|
const existing = edgeMap.get(key);
|
|
5623
5749
|
if (existing) {
|
|
@@ -5650,110 +5776,45 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5650
5776
|
);
|
|
5651
5777
|
}
|
|
5652
5778
|
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
)
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
}
|
|
5671
|
-
}
|
|
5672
|
-
}
|
|
5673
|
-
const allowedMirrorHashes = [...mirrorByDocid.values()];
|
|
5674
|
-
if (allowedMirrorHashes.length === 0) {
|
|
5675
|
-
warnings.push("Similarity unavailable: no embedded nodes in graph");
|
|
5676
|
-
}
|
|
5677
|
-
const allowedPlaceholders = allowedMirrorHashes
|
|
5678
|
-
.map(() => "?")
|
|
5679
|
-
.join(",");
|
|
5680
|
-
|
|
5681
|
-
// Get kNN for each node
|
|
5682
|
-
// Query content_vectors for embedded chunks, find similar
|
|
5683
|
-
for (const docid of nodesForSimilarity) {
|
|
5684
|
-
if (allowedMirrorHashes.length === 0) break;
|
|
5685
|
-
const mirrorHash = mirrorByDocid.get(docid);
|
|
5686
|
-
if (!mirrorHash) continue;
|
|
5687
|
-
|
|
5688
|
-
// Find similar docs using vec_distance, aggregate by doc to get max score
|
|
5689
|
-
interface SimilarRow {
|
|
5690
|
-
target_docid: string;
|
|
5691
|
-
score: number;
|
|
5692
|
-
}
|
|
5693
|
-
|
|
5694
|
-
// Use GROUP BY to get one best score per doc (avoids duplicate rows from multi-chunk docs)
|
|
5695
|
-
const similarQuery = `
|
|
5696
|
-
SELECT
|
|
5697
|
-
d.docid as target_docid,
|
|
5698
|
-
MAX(1 - vec_distance_cosine(v1.embedding, v2.embedding)) as score
|
|
5699
|
-
FROM content_vectors v1
|
|
5700
|
-
JOIN content_vectors v2 ON v2.model = v1.model
|
|
5701
|
-
AND v2.mirror_hash != v1.mirror_hash
|
|
5702
|
-
AND v2.seq = 0
|
|
5703
|
-
JOIN documents d ON d.mirror_hash = v2.mirror_hash AND d.active = 1
|
|
5704
|
-
WHERE v1.mirror_hash = ? AND v1.seq = 0
|
|
5705
|
-
AND d.docid != ?
|
|
5706
|
-
AND v2.mirror_hash IN (${allowedPlaceholders})
|
|
5707
|
-
GROUP BY d.docid
|
|
5708
|
-
HAVING score >= ?
|
|
5709
|
-
ORDER BY score DESC
|
|
5710
|
-
LIMIT ?
|
|
5711
|
-
`;
|
|
5712
|
-
|
|
5713
|
-
try {
|
|
5714
|
-
const similarRows = db
|
|
5715
|
-
.query<SimilarRow, (string | number)[]>(similarQuery)
|
|
5716
|
-
.all(
|
|
5717
|
-
mirrorHash,
|
|
5718
|
-
docid,
|
|
5719
|
-
...allowedMirrorHashes,
|
|
5720
|
-
threshold,
|
|
5721
|
-
similarTopK
|
|
5722
|
-
);
|
|
5723
|
-
|
|
5724
|
-
for (const sim of similarRows) {
|
|
5725
|
-
if (!nodeDocids.has(sim.target_docid)) continue;
|
|
5726
|
-
|
|
5727
|
-
// Clamp score to [0, 1] for schema compliance
|
|
5728
|
-
const clampedScore = Math.max(0, Math.min(1, sim.score));
|
|
5729
|
-
|
|
5779
|
+
const embedModel = options?.embedModel;
|
|
5780
|
+
if (embedModel) {
|
|
5781
|
+
const similarityEdges = storedSimilarityEdges(
|
|
5782
|
+
db,
|
|
5783
|
+
embedModel,
|
|
5784
|
+
nodesForSimilarity,
|
|
5785
|
+
threshold,
|
|
5786
|
+
similarTopK
|
|
5787
|
+
);
|
|
5788
|
+
if (similarityEdges === null) {
|
|
5789
|
+
warnings.push(
|
|
5790
|
+
"Similarity query failed; similarity edges are unavailable"
|
|
5791
|
+
);
|
|
5792
|
+
} else if (similarityEdges.embeddedNodes === 0) {
|
|
5793
|
+
warnings.push("Similarity unavailable: no embedded nodes in graph");
|
|
5794
|
+
} else {
|
|
5795
|
+
for (const edge of similarityEdges.edges) {
|
|
5730
5796
|
// Canonicalize by lexicographic order (undirected edge)
|
|
5731
5797
|
const [a, b] =
|
|
5732
|
-
|
|
5733
|
-
? [
|
|
5734
|
-
: [
|
|
5798
|
+
edge.source < edge.target
|
|
5799
|
+
? [edge.source, edge.target]
|
|
5800
|
+
: [edge.target, edge.source];
|
|
5735
5801
|
const key = `${a}:${b}:similar`;
|
|
5736
5802
|
|
|
5737
5803
|
// Keep max score
|
|
5738
5804
|
const existing = edgeMap.get(key);
|
|
5739
|
-
if (!existing ||
|
|
5805
|
+
if (!existing || edge.score > existing.weight) {
|
|
5740
5806
|
edgeMap.set(key, {
|
|
5741
5807
|
type: "similar",
|
|
5742
|
-
weight:
|
|
5808
|
+
weight: edge.score,
|
|
5743
5809
|
confidence: "similarity",
|
|
5744
|
-
audit: { resolution: "similarity", score:
|
|
5810
|
+
audit: { resolution: "similarity", score: edge.score },
|
|
5745
5811
|
});
|
|
5746
5812
|
}
|
|
5747
5813
|
}
|
|
5748
|
-
} catch {
|
|
5749
|
-
similarityFailures++;
|
|
5750
5814
|
}
|
|
5751
|
-
}
|
|
5752
|
-
|
|
5753
|
-
// Report partial failures
|
|
5754
|
-
if (similarityFailures > 0) {
|
|
5815
|
+
} else {
|
|
5755
5816
|
warnings.push(
|
|
5756
|
-
|
|
5817
|
+
"Similarity unavailable: no embedding model configured"
|
|
5757
5818
|
);
|
|
5758
5819
|
}
|
|
5759
5820
|
} else if (includeSimilar && !similarAvailable) {
|
|
@@ -5893,9 +5954,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5893
5954
|
embedModel?: string;
|
|
5894
5955
|
embedFingerprint?: string;
|
|
5895
5956
|
chunking?: Partial<ChunkingParams>;
|
|
5957
|
+
configuredCollections?: readonly string[];
|
|
5896
5958
|
}): Promise<StoreResult<IndexStatus>> {
|
|
5897
5959
|
try {
|
|
5898
5960
|
const db = this.ensureOpen();
|
|
5961
|
+
// JSON array of configured names, or null when the caller has no config
|
|
5962
|
+
// (every indexed collection is reported).
|
|
5963
|
+
const configuredJson = options?.configuredCollections
|
|
5964
|
+
? JSON.stringify(options.configuredCollections)
|
|
5965
|
+
: null;
|
|
5899
5966
|
const embedModel = options?.embedModel ?? null;
|
|
5900
5967
|
const embedFingerprint =
|
|
5901
5968
|
options?.embedFingerprint ??
|
|
@@ -5927,6 +5994,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5927
5994
|
path: string;
|
|
5928
5995
|
egress_policy: EgressPolicy;
|
|
5929
5996
|
egress_policy_source: EgressPolicySource;
|
|
5997
|
+
workspace_root: string | null;
|
|
5998
|
+
workspace_source: LinkWorkspaceSource | null;
|
|
5930
5999
|
total: number;
|
|
5931
6000
|
active: number;
|
|
5932
6001
|
errored: number;
|
|
@@ -5936,7 +6005,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5936
6005
|
}
|
|
5937
6006
|
|
|
5938
6007
|
const collectionStats = db
|
|
5939
|
-
.query<
|
|
6008
|
+
.query<
|
|
6009
|
+
CollectionStat,
|
|
6010
|
+
[
|
|
6011
|
+
string | null,
|
|
6012
|
+
string | null,
|
|
6013
|
+
string | null,
|
|
6014
|
+
string | null,
|
|
6015
|
+
string | null,
|
|
6016
|
+
]
|
|
6017
|
+
>(
|
|
5940
6018
|
`
|
|
5941
6019
|
WITH document_stats AS (
|
|
5942
6020
|
SELECT
|
|
@@ -5980,6 +6058,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5980
6058
|
c.path,
|
|
5981
6059
|
c.egress_policy,
|
|
5982
6060
|
c.egress_policy_source,
|
|
6061
|
+
c.workspace_root,
|
|
6062
|
+
c.workspace_source,
|
|
5983
6063
|
COALESCE(ds.total, 0) AS total,
|
|
5984
6064
|
COALESCE(ds.active, 0) AS active,
|
|
5985
6065
|
COALESCE(ds.errored, 0) AS errored,
|
|
@@ -5989,29 +6069,51 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5989
6069
|
FROM collections c
|
|
5990
6070
|
LEFT JOIN document_stats ds ON ds.collection = c.name
|
|
5991
6071
|
LEFT JOIN collection_chunks ch ON ch.collection = c.name
|
|
6072
|
+
WHERE ? IS NULL OR c.name IN (SELECT value FROM json_each(?))
|
|
5992
6073
|
ORDER BY c.name
|
|
5993
6074
|
`
|
|
5994
6075
|
)
|
|
5995
|
-
.all(
|
|
6076
|
+
.all(
|
|
6077
|
+
embedModel,
|
|
6078
|
+
embedModel,
|
|
6079
|
+
embedFingerprint,
|
|
6080
|
+
configuredJson,
|
|
6081
|
+
configuredJson
|
|
6082
|
+
);
|
|
5996
6083
|
|
|
5997
|
-
//
|
|
6084
|
+
// Totals cover the configured collections only, so a collection removed
|
|
6085
|
+
// from config stops counting before the next update prunes its rows.
|
|
5998
6086
|
const totalsRow = db
|
|
5999
|
-
.query<
|
|
6087
|
+
.query<
|
|
6088
|
+
{ total: number; active: number },
|
|
6089
|
+
[string | null, string | null]
|
|
6090
|
+
>(
|
|
6000
6091
|
`
|
|
6001
6092
|
SELECT
|
|
6002
6093
|
COUNT(*) as total,
|
|
6003
6094
|
SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) as active
|
|
6004
6095
|
FROM documents
|
|
6096
|
+
WHERE ? IS NULL OR collection IN (SELECT value FROM json_each(?))
|
|
6005
6097
|
`
|
|
6006
6098
|
)
|
|
6007
|
-
.get();
|
|
6099
|
+
.get(configuredJson, configuredJson);
|
|
6008
6100
|
|
|
6101
|
+
// Chunks of active documents only (deduplicated by canonical chunk);
|
|
6102
|
+
// content_chunks keeps rows of deleted documents until cleanup.
|
|
6009
6103
|
const chunkCount =
|
|
6010
6104
|
db
|
|
6011
|
-
.query<{ count: number }, []>(
|
|
6012
|
-
|
|
6105
|
+
.query<{ count: number }, [string | null, string | null]>(
|
|
6106
|
+
`
|
|
6107
|
+
SELECT COUNT(*) as count
|
|
6108
|
+
FROM content_chunks
|
|
6109
|
+
WHERE mirror_hash IN (
|
|
6110
|
+
SELECT mirror_hash FROM documents
|
|
6111
|
+
WHERE active = 1 AND mirror_hash IS NOT NULL
|
|
6112
|
+
AND (? IS NULL OR collection IN (SELECT value FROM json_each(?)))
|
|
6113
|
+
)
|
|
6114
|
+
`
|
|
6013
6115
|
)
|
|
6014
|
-
.get()?.count ?? 0;
|
|
6116
|
+
.get(configuredJson, configuredJson)?.count ?? 0;
|
|
6015
6117
|
|
|
6016
6118
|
// Embedding backlog: chunks from active docs without vectors
|
|
6017
6119
|
// Uses EXISTS to avoid duplicates when multiple docs share mirror_hash
|
|
@@ -6081,6 +6183,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6081
6183
|
path: s.path,
|
|
6082
6184
|
egressPolicy: s.egress_policy,
|
|
6083
6185
|
egressPolicySource: s.egress_policy_source,
|
|
6186
|
+
workspaceRoot: s.workspace_root,
|
|
6187
|
+
workspaceSource: s.workspace_source ?? "none",
|
|
6084
6188
|
totalDocuments: s.total,
|
|
6085
6189
|
activeDocuments: s.active,
|
|
6086
6190
|
errorDocuments: s.errored,
|
|
@@ -6357,6 +6461,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6357
6461
|
}
|
|
6358
6462
|
}
|
|
6359
6463
|
|
|
6464
|
+
/** Collection names as the config schema admits them (safe to inline). */
|
|
6465
|
+
const COLLECTION_IDENTIFIER = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
6466
|
+
|
|
6467
|
+
/**
|
|
6468
|
+
* Workspace columns on collection upsert. Nested vault prefixes are refreshed
|
|
6469
|
+
* by ingestion; they stay valid only while the collection's real root does.
|
|
6470
|
+
*/
|
|
6471
|
+
const COLLECTION_WORKSPACE_UPSERT_SET = `real_path = excluded.real_path,
|
|
6472
|
+
workspace_root = excluded.workspace_root,
|
|
6473
|
+
workspace_source = excluded.workspace_source,
|
|
6474
|
+
workspace_nested = CASE
|
|
6475
|
+
WHEN collections.real_path IS excluded.real_path
|
|
6476
|
+
THEN collections.workspace_nested
|
|
6477
|
+
ELSE NULL
|
|
6478
|
+
END,`;
|
|
6479
|
+
|
|
6360
6480
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
6361
6481
|
// DB Row Types (snake_case from SQLite)
|
|
6362
6482
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -6371,6 +6491,10 @@ interface DbCollectionRow {
|
|
|
6371
6491
|
language_hint: string | null;
|
|
6372
6492
|
egress_policy: EgressPolicy;
|
|
6373
6493
|
egress_policy_source: EgressPolicySource;
|
|
6494
|
+
real_path?: string | null;
|
|
6495
|
+
workspace_root?: string | null;
|
|
6496
|
+
workspace_source?: LinkWorkspaceSource | null;
|
|
6497
|
+
workspace_nested?: string | null;
|
|
6374
6498
|
synced_at: string;
|
|
6375
6499
|
}
|
|
6376
6500
|
|
|
@@ -6462,6 +6586,18 @@ interface DbIngestErrorRow {
|
|
|
6462
6586
|
// Row Mappers (snake_case -> camelCase)
|
|
6463
6587
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
6464
6588
|
|
|
6589
|
+
function parseWorkspaceNested(raw: string | null): string[] {
|
|
6590
|
+
if (!raw) return [];
|
|
6591
|
+
try {
|
|
6592
|
+
const parsed: unknown = JSON.parse(raw);
|
|
6593
|
+
return Array.isArray(parsed)
|
|
6594
|
+
? parsed.filter((value): value is string => typeof value === "string")
|
|
6595
|
+
: [];
|
|
6596
|
+
} catch {
|
|
6597
|
+
return [];
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
|
|
6465
6601
|
function mapCollectionRow(row: DbCollectionRow): CollectionRow {
|
|
6466
6602
|
return {
|
|
6467
6603
|
name: row.name,
|
|
@@ -6473,6 +6609,10 @@ function mapCollectionRow(row: DbCollectionRow): CollectionRow {
|
|
|
6473
6609
|
languageHint: row.language_hint,
|
|
6474
6610
|
egressPolicy: row.egress_policy,
|
|
6475
6611
|
egressPolicySource: row.egress_policy_source,
|
|
6612
|
+
realPath: row.real_path ?? null,
|
|
6613
|
+
workspaceRoot: row.workspace_root ?? null,
|
|
6614
|
+
workspaceSource: row.workspace_source ?? "none",
|
|
6615
|
+
workspaceNested: parseWorkspaceNested(row.workspace_nested ?? null),
|
|
6476
6616
|
syncedAt: row.synced_at,
|
|
6477
6617
|
};
|
|
6478
6618
|
}
|