@untangle-code/file-graph 0.0.0-stage → 0.1.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/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +284 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/assignment.d.ts +15 -0
- package/dist/assignment.d.ts.map +1 -0
- package/dist/assignment.js +75 -0
- package/dist/assignment.js.map +1 -0
- package/dist/boundaries/endpoints.d.ts +11 -0
- package/dist/boundaries/endpoints.d.ts.map +1 -0
- package/dist/boundaries/endpoints.js +195 -0
- package/dist/boundaries/endpoints.js.map +1 -0
- package/dist/boundaries/index.d.ts +9 -0
- package/dist/boundaries/index.d.ts.map +1 -0
- package/dist/boundaries/index.js +93 -0
- package/dist/boundaries/index.js.map +1 -0
- package/dist/boundaries/requests.d.ts +12 -0
- package/dist/boundaries/requests.d.ts.map +1 -0
- package/dist/boundaries/requests.js +311 -0
- package/dist/boundaries/requests.js.map +1 -0
- package/dist/edges.d.ts +49 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +223 -0
- package/dist/edges.js.map +1 -0
- package/dist/file-index.d.ts +37 -0
- package/dist/file-index.d.ts.map +1 -0
- package/dist/file-index.js +103 -0
- package/dist/file-index.js.map +1 -0
- package/dist/graph.d.ts +160 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +448 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +67 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +272 -0
- package/dist/metrics.js.map +1 -0
- package/dist/package-catalog.d.ts +43 -0
- package/dist/package-catalog.d.ts.map +1 -0
- package/dist/package-catalog.js +136 -0
- package/dist/package-catalog.js.map +1 -0
- package/dist/paths.d.ts +56 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +129 -0
- package/dist/paths.js.map +1 -0
- package/dist/persistence.d.ts +83 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +538 -0
- package/dist/persistence.js.map +1 -0
- package/dist/resolve/candidates.d.ts +52 -0
- package/dist/resolve/candidates.d.ts.map +1 -0
- package/dist/resolve/candidates.js +202 -0
- package/dist/resolve/candidates.js.map +1 -0
- package/dist/resolve/package-exports.d.ts +31 -0
- package/dist/resolve/package-exports.d.ts.map +1 -0
- package/dist/resolve/package-exports.js +229 -0
- package/dist/resolve/package-exports.js.map +1 -0
- package/dist/resolve/pipeline.d.ts +11 -0
- package/dist/resolve/pipeline.d.ts.map +1 -0
- package/dist/resolve/pipeline.js +109 -0
- package/dist/resolve/pipeline.js.map +1 -0
- package/dist/resolve/resolvers.d.ts +25 -0
- package/dist/resolve/resolvers.d.ts.map +1 -0
- package/dist/resolve/resolvers.js +367 -0
- package/dist/resolve/resolvers.js.map +1 -0
- package/dist/restore.d.ts +75 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +136 -0
- package/dist/restore.js.map +1 -0
- package/dist/topology/compose.d.ts +27 -0
- package/dist/topology/compose.d.ts.map +1 -0
- package/dist/topology/compose.js +208 -0
- package/dist/topology/compose.js.map +1 -0
- package/dist/topology/jsonc.d.ts +38 -0
- package/dist/topology/jsonc.d.ts.map +1 -0
- package/dist/topology/jsonc.js +137 -0
- package/dist/topology/jsonc.js.map +1 -0
- package/dist/topology/load.d.ts +61 -0
- package/dist/topology/load.d.ts.map +1 -0
- package/dist/topology/load.js +436 -0
- package/dist/topology/load.js.map +1 -0
- package/dist/topology/manifest.d.ts +48 -0
- package/dist/topology/manifest.d.ts.map +1 -0
- package/dist/topology/manifest.js +109 -0
- package/dist/topology/manifest.js.map +1 -0
- package/dist/topology/project-config.d.ts +39 -0
- package/dist/topology/project-config.d.ts.map +1 -0
- package/dist/topology/project-config.js +150 -0
- package/dist/topology/project-config.js.map +1 -0
- package/dist/topology/workspace-definition.d.ts +26 -0
- package/dist/topology/workspace-definition.d.ts.map +1 -0
- package/dist/topology/workspace-definition.js +104 -0
- package/dist/topology/workspace-definition.js.map +1 -0
- package/dist/traverse.d.ts +49 -0
- package/dist/traverse.d.ts.map +1 -0
- package/dist/traverse.js +167 -0
- package/dist/traverse.js.map +1 -0
- package/dist/types.d.ts +332 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -3
- package/src/assignment.ts +83 -0
- package/src/boundaries/endpoints.ts +273 -0
- package/src/boundaries/index.ts +118 -0
- package/src/boundaries/requests.ts +411 -0
- package/src/edges.ts +334 -0
- package/src/file-index.ts +127 -0
- package/src/graph.ts +591 -0
- package/src/index.ts +58 -0
- package/src/metrics.ts +350 -0
- package/src/package-catalog.ts +145 -0
- package/src/paths.ts +138 -0
- package/src/persistence.ts +746 -0
- package/src/resolve/candidates.ts +230 -0
- package/src/resolve/package-exports.ts +268 -0
- package/src/resolve/pipeline.ts +140 -0
- package/src/resolve/resolvers.ts +468 -0
- package/src/restore.ts +201 -0
- package/src/topology/compose.ts +268 -0
- package/src/topology/jsonc.ts +147 -0
- package/src/topology/load.ts +644 -0
- package/src/topology/manifest.ts +132 -0
- package/src/topology/project-config.ts +183 -0
- package/src/topology/workspace-definition.ts +115 -0
- package/src/traverse.ts +232 -0
- package/src/types.ts +372 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ablage des Dateigraphen ueber den BESTEHENDEN `GraphStore`-Port
|
|
3
|
+
* (domain `ports.ts`, architecture.md §6.11, §11.6).
|
|
4
|
+
*
|
|
5
|
+
* ENTSCHEIDUNG: Dieses Paket definiert KEINEN eigenen Speicher-Port. Der
|
|
6
|
+
* `GraphStore` von `domain` hat mit `graph_files`, `graph_indices` und
|
|
7
|
+
* `graph_meta` genau die drei Sammlungen, die ein Dateigraph braucht, und es
|
|
8
|
+
* gibt bereits eine Kontraktsuite (`@untangle-code/testkit`,
|
|
9
|
+
* `runGraphStoreContract`) plus zwei Adapter dagegen. Ein zweiter Port haette
|
|
10
|
+
* dieselbe Semantik ein zweites Mal beschrieben und beide Adapter zur
|
|
11
|
+
* Doppelimplementierung gezwungen.
|
|
12
|
+
*
|
|
13
|
+
* SHARD-AUFTEILUNG:
|
|
14
|
+
* - `graph_meta` / `<workspace>:meta` — Metriken, Topologie, Skip-Ledger, Knotenzahl;
|
|
15
|
+
* - `graph_files` / `<workspace>:file:<file_id>` — ein Knoten samt seiner Kanten;
|
|
16
|
+
* - `graph_indices` / `<workspace>:by-path` — Repository + Pfad → `file_id`.
|
|
17
|
+
*
|
|
18
|
+
* Ein Shard je Datei (statt eines Riesenshards) ist Absicht: die inkrementelle
|
|
19
|
+
* Fortschreibung schreibt dann genau die Dateien zurueck, die sie neu gerechnet
|
|
20
|
+
* hat.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
FileEdgeV1,
|
|
24
|
+
FileNodeV1,
|
|
25
|
+
GraphShardV1,
|
|
26
|
+
GraphStore,
|
|
27
|
+
PortCallOptions,
|
|
28
|
+
} from "@untangle-code/domain";
|
|
29
|
+
import { compareCodepoints } from "@untangle-code/domain";
|
|
30
|
+
|
|
31
|
+
import { compareEdges } from "./edges.js";
|
|
32
|
+
import { normalizePath } from "./paths.js";
|
|
33
|
+
import type { FileGraphSnapshotV1 } from "./types.js";
|
|
34
|
+
|
|
35
|
+
/** Vor Wave 8 verwendete globale Metadaten-ID; nur fuer expliziten Legacy-Fallback. */
|
|
36
|
+
export const META_SHARD_ID = "meta";
|
|
37
|
+
/** Vor Wave 8 verwendete globale Pfadindex-ID; nur fuer expliziten Legacy-Fallback. */
|
|
38
|
+
export const PATH_INDEX_SHARD_ID = "by-path";
|
|
39
|
+
/** Versionierter Praefix der workspace-sicheren Dateigraph-Shards. */
|
|
40
|
+
export const FILE_GRAPH_SHARD_NAMESPACE = "untangle:file-graph:v1";
|
|
41
|
+
|
|
42
|
+
/** Der vollstaendige Geltungsbereich eines persistierten Graphabbilds. */
|
|
43
|
+
export interface GraphSnapshotScope {
|
|
44
|
+
workspaceId: string;
|
|
45
|
+
generationId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Opt-in fuer das Lesen vor Wave 8 erzeugter, nicht namespaceter Shards. */
|
|
49
|
+
export interface GraphSnapshotReadOptions extends PortCallOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Nur ein explizites `true` aktiviert den Legacy-Pfad. Auch dann wird der
|
|
52
|
+
* gelesene Payload strikt gegen `workspaceId` UND `generationId` geprueft.
|
|
53
|
+
*/
|
|
54
|
+
allowLegacyFallback?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Praefix aller Dateigraph-Shards eines Workspace. */
|
|
58
|
+
export function fileGraphWorkspaceShardPrefix(workspaceId: string): string {
|
|
59
|
+
assertIdentifier("workspaceId", workspaceId);
|
|
60
|
+
return `${FILE_GRAPH_SHARD_NAMESPACE}:${String(workspaceId.length)}:${workspaceId}:`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Namespacete ID des Metadaten-Shards. */
|
|
64
|
+
export function fileGraphMetaShardId(workspaceId: string): string {
|
|
65
|
+
return `${fileGraphWorkspaceShardPrefix(workspaceId)}meta`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Namespacete ID des Pfadindex-Shards. */
|
|
69
|
+
export function fileGraphPathIndexShardId(workspaceId: string): string {
|
|
70
|
+
return `${fileGraphWorkspaceShardPrefix(workspaceId)}by-path`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Namespacete ID eines Datei-Shards. */
|
|
74
|
+
export function fileGraphFileShardId(workspaceId: string, fileId: string): string {
|
|
75
|
+
assertIdentifier("fileId", fileId);
|
|
76
|
+
return `${fileGraphWorkspaceShardPrefix(workspaceId)}file:${fileId}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Erzeugt alle Shards eines Abbilds, in stabiler Reihenfolge. */
|
|
80
|
+
export function toGraphShards(snapshot: FileGraphSnapshotV1, writeId: string): GraphShardV1[] {
|
|
81
|
+
assertFileGraphSnapshotScope(snapshot);
|
|
82
|
+
assertIdentifier("writeId", writeId);
|
|
83
|
+
|
|
84
|
+
const edgesByFrom = new Map<string, FileEdgeV1[]>();
|
|
85
|
+
for (const edge of snapshot.edges) {
|
|
86
|
+
const bucket = edgesByFrom.get(edge.fromFileId) ?? [];
|
|
87
|
+
bucket.push(edge);
|
|
88
|
+
edgesByFrom.set(edge.fromFileId, bucket);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const shards: GraphShardV1[] = [
|
|
92
|
+
{
|
|
93
|
+
logicalShardId: fileGraphMetaShardId(snapshot.workspaceId),
|
|
94
|
+
collection: "graph_meta",
|
|
95
|
+
generationId: snapshot.graphGeneration,
|
|
96
|
+
writeId,
|
|
97
|
+
payload: {
|
|
98
|
+
persistenceSchemaVersion: 1,
|
|
99
|
+
schemaVersion: snapshot.schemaVersion,
|
|
100
|
+
workspaceId: snapshot.workspaceId,
|
|
101
|
+
graphGeneration: snapshot.graphGeneration,
|
|
102
|
+
nodeCount: snapshot.nodes.length,
|
|
103
|
+
edgeCount: snapshot.edges.length,
|
|
104
|
+
metrics: snapshot.metrics,
|
|
105
|
+
topology: snapshot.topology,
|
|
106
|
+
skipped: snapshot.skipped,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const pathIndex: Record<string, string> = {};
|
|
112
|
+
/**
|
|
113
|
+
* WP-197: Zwei Knoten mit DEMSELBEN Repository-Pfad ueberschrieben einander
|
|
114
|
+
* im Pfadindex — ohne Befund, ohne Ledger. Der Pfadindex ist aber genau der
|
|
115
|
+
* Zeuge, aus dem `readGraphSnapshot` seine Erwartungsmenge bildet: die
|
|
116
|
+
* verlorene `file_id` wurde damit auch beim Zurueckladen nie vermisst, und
|
|
117
|
+
* `missing_file_shard` konnte sie nicht nennen. Der Verlust war doppelt
|
|
118
|
+
* verdeckt. Jetzt behaelt der Index den ERSTEN (nach `file_id` kleinsten)
|
|
119
|
+
* Eintrag — bestimmt und damit reproduzierbar — und die Kollision steht als
|
|
120
|
+
* eigener Eintrag im Skip-Ledger des Abbilds.
|
|
121
|
+
*/
|
|
122
|
+
const pathCollisions: { key: string; kept: string; dropped: string }[] = [];
|
|
123
|
+
for (const node of [...snapshot.nodes].sort((left, right) =>
|
|
124
|
+
compareCodepoints(left.fileId, right.fileId),
|
|
125
|
+
)) {
|
|
126
|
+
const key = pathKey(node.repoId, node.filePath);
|
|
127
|
+
const existing = pathIndex[key];
|
|
128
|
+
if (existing !== undefined) {
|
|
129
|
+
pathCollisions.push({ key, kept: existing, dropped: node.fileId });
|
|
130
|
+
} else {
|
|
131
|
+
pathIndex[key] = node.fileId;
|
|
132
|
+
}
|
|
133
|
+
shards.push({
|
|
134
|
+
logicalShardId: fileGraphFileShardId(snapshot.workspaceId, node.fileId),
|
|
135
|
+
collection: "graph_files",
|
|
136
|
+
generationId: snapshot.graphGeneration,
|
|
137
|
+
writeId,
|
|
138
|
+
payload: {
|
|
139
|
+
persistenceSchemaVersion: 1,
|
|
140
|
+
workspaceId: snapshot.workspaceId,
|
|
141
|
+
graphGeneration: snapshot.graphGeneration,
|
|
142
|
+
node,
|
|
143
|
+
edges: (edgesByFrom.get(node.fileId) ?? []).sort(compareEdges),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
shards.push({
|
|
149
|
+
logicalShardId: fileGraphPathIndexShardId(snapshot.workspaceId),
|
|
150
|
+
collection: "graph_indices",
|
|
151
|
+
generationId: snapshot.graphGeneration,
|
|
152
|
+
writeId,
|
|
153
|
+
payload: {
|
|
154
|
+
persistenceSchemaVersion: 1,
|
|
155
|
+
workspaceId: snapshot.workspaceId,
|
|
156
|
+
graphGeneration: snapshot.graphGeneration,
|
|
157
|
+
pathIndex,
|
|
158
|
+
// Die verdraengten `file_id`s stehen NEBEN dem Index, nicht in ihm: der
|
|
159
|
+
// Index bleibt damit formgleich zu bisherigen Generationen, und der
|
|
160
|
+
// Verlust ist trotzdem benannt und nachlesbar.
|
|
161
|
+
...(pathCollisions.length === 0 ? {} : { pathIndexCollisions: pathCollisions }),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return shards;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Schreibt ein vollstaendiges Abbild in den Speicher. */
|
|
169
|
+
export async function writeGraphSnapshot(
|
|
170
|
+
store: GraphStore,
|
|
171
|
+
snapshot: FileGraphSnapshotV1,
|
|
172
|
+
writeId: string,
|
|
173
|
+
options?: PortCallOptions,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
await store.writeShards(toGraphShards(snapshot, writeId), options);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Fehler beim Zurueckladen — als Wert, damit der Aufrufer ihn ins Ledger legen
|
|
180
|
+
* kann.
|
|
181
|
+
*
|
|
182
|
+
* `missing_path_index` und `count_mismatch` sind KEINE Zierde: ohne sie
|
|
183
|
+
* verlaeuft der haeufigste Verlustfall (ein Datei-Shard ist nicht mehr da und
|
|
184
|
+
* wird deshalb auch nicht mehr aufgezaehlt) voellig geraeuschlos. Der
|
|
185
|
+
* Pfadindex und die im Metadaten-Shard mitgeschriebenen Sollzahlen sind die
|
|
186
|
+
* beiden unabhaengigen Zeugen dafuer, WIE VIEL da sein muesste.
|
|
187
|
+
*/
|
|
188
|
+
export interface SnapshotReadIssue {
|
|
189
|
+
kind:
|
|
190
|
+
| "missing_meta"
|
|
191
|
+
| "missing_path_index"
|
|
192
|
+
| "missing_file_shard"
|
|
193
|
+
| "malformed_payload"
|
|
194
|
+
| "count_mismatch"
|
|
195
|
+
| "workspace_scope_required"
|
|
196
|
+
| "scope_mismatch";
|
|
197
|
+
message: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface SnapshotReadResult {
|
|
201
|
+
snapshot: FileGraphSnapshotV1 | null;
|
|
202
|
+
issues: SnapshotReadIssue[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface GraphShardLayout {
|
|
206
|
+
metaShardId: string;
|
|
207
|
+
pathIndexShardId: string;
|
|
208
|
+
fileShardId(fileId: string): string;
|
|
209
|
+
ownsFileShard(shardId: string): boolean;
|
|
210
|
+
legacy: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface FileShardCandidate {
|
|
214
|
+
shardId: string;
|
|
215
|
+
expectedFileId: string | null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Bound store pressure while avoiding one network roundtrip per file in series. */
|
|
219
|
+
const FILE_SHARD_READ_CONCURRENCY = 8;
|
|
220
|
+
|
|
221
|
+
async function readFileShards(
|
|
222
|
+
store: GraphStore,
|
|
223
|
+
candidates: readonly FileShardCandidate[],
|
|
224
|
+
generationId: string,
|
|
225
|
+
options: PortCallOptions,
|
|
226
|
+
): Promise<Array<FileShardCandidate & { shard: GraphShardV1 | null }>> {
|
|
227
|
+
const loaded: Array<FileShardCandidate & { shard: GraphShardV1 | null }> = [];
|
|
228
|
+
for (let offset = 0; offset < candidates.length; offset += FILE_SHARD_READ_CONCURRENCY) {
|
|
229
|
+
options.signal?.throwIfAborted();
|
|
230
|
+
const batch = candidates.slice(offset, offset + FILE_SHARD_READ_CONCURRENCY);
|
|
231
|
+
const results = await Promise.allSettled(
|
|
232
|
+
batch.map(async (candidate) => ({
|
|
233
|
+
...candidate,
|
|
234
|
+
shard: await store.readShard("graph_files", candidate.shardId, {
|
|
235
|
+
...options,
|
|
236
|
+
generationId,
|
|
237
|
+
}),
|
|
238
|
+
})),
|
|
239
|
+
);
|
|
240
|
+
// Wait for this bounded batch to settle before propagating the first error
|
|
241
|
+
// in candidate order. No later batch starts after a failure, and no read
|
|
242
|
+
// outlives the call. Completion timing never changes issue/error ordering.
|
|
243
|
+
for (const result of results) {
|
|
244
|
+
if (result.status === "rejected") throw result.reason;
|
|
245
|
+
loaded.push(result.value);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return loaded;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Laedt ein Abbild fuer GENAU einen Workspace und GENAU eine Generation.
|
|
253
|
+
*
|
|
254
|
+
* Die alte Signatur `(store, generationId)` bleibt absichtlich typkompatibel,
|
|
255
|
+
* liest aber nichts mehr: ohne Workspace kann kein gespeicherter Graph sicher
|
|
256
|
+
* zugeordnet werden. Alte, globale Shards werden nur ueber
|
|
257
|
+
* `allowLegacyFallback: true` gelesen, nachdem ein vollstaendiger Scope
|
|
258
|
+
* angegeben wurde; jeder Payload wird dabei gegen diesen Scope geprueft.
|
|
259
|
+
*/
|
|
260
|
+
export async function readGraphSnapshot(
|
|
261
|
+
store: GraphStore,
|
|
262
|
+
scope: GraphSnapshotScope | string,
|
|
263
|
+
options: GraphSnapshotReadOptions = {},
|
|
264
|
+
): Promise<SnapshotReadResult> {
|
|
265
|
+
if (typeof scope === "string") {
|
|
266
|
+
return {
|
|
267
|
+
snapshot: null,
|
|
268
|
+
issues: [
|
|
269
|
+
{
|
|
270
|
+
kind: "workspace_scope_required",
|
|
271
|
+
message: `generation "${scope}" was supplied without a workspaceId; use { workspaceId, generationId } so graph reads cannot cross workspace boundaries`,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
assertGraphSnapshotScope(scope);
|
|
277
|
+
const { allowLegacyFallback, ...callOptions } = options;
|
|
278
|
+
|
|
279
|
+
const scopedLayout: GraphShardLayout = {
|
|
280
|
+
metaShardId: fileGraphMetaShardId(scope.workspaceId),
|
|
281
|
+
pathIndexShardId: fileGraphPathIndexShardId(scope.workspaceId),
|
|
282
|
+
fileShardId: (fileId) => fileGraphFileShardId(scope.workspaceId, fileId),
|
|
283
|
+
ownsFileShard: (shardId) =>
|
|
284
|
+
shardId.startsWith(`${fileGraphWorkspaceShardPrefix(scope.workspaceId)}file:`),
|
|
285
|
+
legacy: false,
|
|
286
|
+
};
|
|
287
|
+
const scoped = await readGraphSnapshotFromLayout(store, scope, scopedLayout, callOptions);
|
|
288
|
+
if (
|
|
289
|
+
allowLegacyFallback !== true ||
|
|
290
|
+
scoped.snapshot !== null ||
|
|
291
|
+
scoped.issues.some((issue) => issue.kind !== "missing_meta")
|
|
292
|
+
) {
|
|
293
|
+
return scoped;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return readGraphSnapshotFromLayout(
|
|
297
|
+
store,
|
|
298
|
+
scope,
|
|
299
|
+
{
|
|
300
|
+
metaShardId: META_SHARD_ID,
|
|
301
|
+
pathIndexShardId: PATH_INDEX_SHARD_ID,
|
|
302
|
+
fileShardId: (fileId) => fileId,
|
|
303
|
+
ownsFileShard: () => true,
|
|
304
|
+
legacy: true,
|
|
305
|
+
},
|
|
306
|
+
callOptions,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Eigentliche Rueckladung fuer eine namespacete oder explizit angeforderte
|
|
312
|
+
* Legacy-Ablage. Kein Store-Aufruf verzichtet auf `generationId`.
|
|
313
|
+
*/
|
|
314
|
+
async function readGraphSnapshotFromLayout(
|
|
315
|
+
store: GraphStore,
|
|
316
|
+
scope: GraphSnapshotScope,
|
|
317
|
+
layout: GraphShardLayout,
|
|
318
|
+
options: PortCallOptions,
|
|
319
|
+
): Promise<SnapshotReadResult> {
|
|
320
|
+
const issues: SnapshotReadIssue[] = [];
|
|
321
|
+
const { generationId, workspaceId } = scope;
|
|
322
|
+
const metaShard = await store.readShard("graph_meta", layout.metaShardId, {
|
|
323
|
+
...options,
|
|
324
|
+
generationId,
|
|
325
|
+
});
|
|
326
|
+
if (metaShard === null) {
|
|
327
|
+
issues.push({
|
|
328
|
+
kind: "missing_meta",
|
|
329
|
+
message: `graph_meta/${layout.metaShardId} is absent for workspace "${workspaceId}" and generation "${generationId}"`,
|
|
330
|
+
});
|
|
331
|
+
return { snapshot: null, issues };
|
|
332
|
+
}
|
|
333
|
+
const meta = metaShard.payload;
|
|
334
|
+
|
|
335
|
+
// Der Metadaten-Shard traegt die Pflichtteile des Abbilds. Fehlt einer, kann
|
|
336
|
+
// `FileGraphSnapshotV1` nicht erfuellt werden — dann ist `null` mit
|
|
337
|
+
// benanntem Befund die einzige ehrliche Antwort. Ein Objekt mit
|
|
338
|
+
// `metrics: undefined` an einen Aufrufer zu geben, der laut Vertrag
|
|
339
|
+
// `snapshot.skipped.entries` lesen darf, ist ein verstecktes Loch (E4).
|
|
340
|
+
const metaIssues = validateMetaPayload(meta, scope, layout.metaShardId, metaShard.generationId);
|
|
341
|
+
if (metaIssues.length > 0) {
|
|
342
|
+
issues.push(...metaIssues);
|
|
343
|
+
return { snapshot: null, issues };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nodes: FileNodeV1[] = [];
|
|
347
|
+
const edges: FileEdgeV1[] = [];
|
|
348
|
+
|
|
349
|
+
// Erwartungsmenge NICHT aus `listShardIds` ableiten: ein Shard, den es nicht
|
|
350
|
+
// mehr gibt, wird schlicht nicht aufgezaehlt, und ein Vergleich der Liste mit
|
|
351
|
+
// sich selbst kann nie etwas finden. Der Pfadindex ist der unabhaengige
|
|
352
|
+
// Zeuge, welche `file_id`s zu dieser Generation gehoeren.
|
|
353
|
+
const pathIndexShard = await store.readShard("graph_indices", layout.pathIndexShardId, {
|
|
354
|
+
...options,
|
|
355
|
+
generationId,
|
|
356
|
+
});
|
|
357
|
+
if (pathIndexShard !== null) {
|
|
358
|
+
issues.push(
|
|
359
|
+
...validatePayloadScope(
|
|
360
|
+
pathIndexShard.payload,
|
|
361
|
+
scope,
|
|
362
|
+
`graph_indices/${layout.pathIndexShardId}`,
|
|
363
|
+
pathIndexShard.generationId,
|
|
364
|
+
layout.legacy,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
if (issues.some((issue) => issue.kind === "scope_mismatch")) {
|
|
368
|
+
return { snapshot: null, issues };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// WP-197: Die beim Schreiben verdraengten `file_id`s stehen im Payload des
|
|
372
|
+
// Pfadindex. Sie hier zu ueberlesen hiesse, den Verlust genau an der Stelle
|
|
373
|
+
// zu verschweigen, an der er wirkt: `expectedFileIds` kennt sie nicht, also
|
|
374
|
+
// koennte `missing_file_shard` sie auch nie vermissen.
|
|
375
|
+
const collisions = pathIndexShard?.payload["pathIndexCollisions"];
|
|
376
|
+
if (Array.isArray(collisions)) {
|
|
377
|
+
for (const collision of collisions) {
|
|
378
|
+
if (!isRecord(collision)) continue;
|
|
379
|
+
issues.push({
|
|
380
|
+
kind: "malformed_payload",
|
|
381
|
+
message: `graph_indices/${layout.pathIndexShardId} of generation "${generationId}" records a path collision: "${String(collision["key"])}" maps to file id "${String(collision["kept"])}", so "${String(collision["dropped"])}" is NOT named by the path index and a missing shard for it could not be detected`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const expectedFileIds = expectedFileIdsOf(pathIndexShard?.payload);
|
|
386
|
+
if (expectedFileIds === null) {
|
|
387
|
+
issues.push({
|
|
388
|
+
kind: "missing_path_index",
|
|
389
|
+
message:
|
|
390
|
+
pathIndexShard === null
|
|
391
|
+
? `graph_indices/${layout.pathIndexShardId} is absent for generation "${generationId}"; missing file shards cannot be named, only counted`
|
|
392
|
+
: `graph_indices/${layout.pathIndexShardId} has no usable "pathIndex" payload for generation "${generationId}"; missing file shards cannot be named, only counted`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const shardCandidates =
|
|
397
|
+
expectedFileIds === null
|
|
398
|
+
? (await store.listShardIds("graph_files", { ...options, generationId }))
|
|
399
|
+
.filter(layout.ownsFileShard)
|
|
400
|
+
.sort(compareCodepoints)
|
|
401
|
+
.map((shardId) => ({ shardId, expectedFileId: null }))
|
|
402
|
+
: expectedFileIds.map((fileId) => ({
|
|
403
|
+
shardId: layout.fileShardId(fileId),
|
|
404
|
+
expectedFileId: fileId,
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
const fileShards = await readFileShards(store, shardCandidates, generationId, options);
|
|
408
|
+
for (const { shardId, expectedFileId, shard } of fileShards) {
|
|
409
|
+
if (shard === null) {
|
|
410
|
+
issues.push({
|
|
411
|
+
kind: "missing_file_shard",
|
|
412
|
+
message:
|
|
413
|
+
expectedFileId === null
|
|
414
|
+
? `graph_files/${shardId} was listed but could not be read`
|
|
415
|
+
: `graph_files/${shardId} is named by the path index of generation "${generationId}" but is absent from the store`,
|
|
416
|
+
});
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const payloadScopeIssues = validatePayloadScope(
|
|
420
|
+
shard.payload,
|
|
421
|
+
scope,
|
|
422
|
+
`graph_files/${shardId}`,
|
|
423
|
+
shard.generationId,
|
|
424
|
+
layout.legacy,
|
|
425
|
+
);
|
|
426
|
+
issues.push(...payloadScopeIssues);
|
|
427
|
+
if (payloadScopeIssues.length > 0) continue;
|
|
428
|
+
|
|
429
|
+
const node = shard.payload["node"];
|
|
430
|
+
const shardEdges = shard.payload["edges"];
|
|
431
|
+
if (!isRecord(node) || !Array.isArray(shardEdges)) {
|
|
432
|
+
issues.push({
|
|
433
|
+
kind: "malformed_payload",
|
|
434
|
+
message: `graph_files/${shardId} has no usable "node"/"edges" payload`,
|
|
435
|
+
});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
// Der Shard und der Knoten darin muessen DIESELBE Datei sein.
|
|
439
|
+
//
|
|
440
|
+
// Ohne diesen Vergleich ist die haeufigste zahlenerhaltende Verfaelschung
|
|
441
|
+
// unsichtbar: traegt `graph_files/F_c` den Knoten von `F_a`, dann ist der
|
|
442
|
+
// Shard gelistet, der Pfadindex nennt ihn, und drei Shards liefern drei
|
|
443
|
+
// Knoten — beide bisherigen Zeugen (`missing_file_shard`,
|
|
444
|
+
// `count_mismatch`) bescheinigen dem Abbild ausdruecklich
|
|
445
|
+
// Vollstaendigkeit, waehrend `F_c` verschwunden und `F_a` doppelt ist.
|
|
446
|
+
// Ein Abbild, das die Eindeutigkeit der `file_id` verletzt, ist fuer
|
|
447
|
+
// jeden Ableiter falsch, und es unbenannt zu liefern ist genau das
|
|
448
|
+
// stille Ueberspringen, das E4 verbietet.
|
|
449
|
+
const nodeFileId = node["fileId"];
|
|
450
|
+
const expectedPhysicalShardId =
|
|
451
|
+
typeof nodeFileId === "string" && nodeFileId !== "" ? layout.fileShardId(nodeFileId) : null;
|
|
452
|
+
if (
|
|
453
|
+
expectedPhysicalShardId === null ||
|
|
454
|
+
expectedPhysicalShardId !== shardId ||
|
|
455
|
+
(expectedFileId !== null && nodeFileId !== expectedFileId)
|
|
456
|
+
) {
|
|
457
|
+
issues.push({
|
|
458
|
+
kind: "malformed_payload",
|
|
459
|
+
message: `graph_files/${shardId} carries a node with fileId ${
|
|
460
|
+
typeof nodeFileId === "string" ? `"${nodeFileId}"` : "of a non-string type"
|
|
461
|
+
}; the shard and the node it carries must be the same file`,
|
|
462
|
+
});
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (node["workspaceId"] !== workspaceId || node["graphGeneration"] !== generationId) {
|
|
466
|
+
issues.push({
|
|
467
|
+
kind: "scope_mismatch",
|
|
468
|
+
message: `graph_files/${shardId} carries a node outside requested workspace "${workspaceId}" or generation "${generationId}"`,
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const edgeScopeMismatch = shardEdges.some(
|
|
473
|
+
(edge) =>
|
|
474
|
+
!isRecord(edge) ||
|
|
475
|
+
edge["workspaceId"] !== workspaceId ||
|
|
476
|
+
edge["graphGeneration"] !== generationId ||
|
|
477
|
+
edge["fromFileId"] !== nodeFileId,
|
|
478
|
+
);
|
|
479
|
+
if (edgeScopeMismatch) {
|
|
480
|
+
issues.push({
|
|
481
|
+
kind: "scope_mismatch",
|
|
482
|
+
message: `graph_files/${shardId} carries an edge outside the requested scope or source file`,
|
|
483
|
+
});
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
nodes.push(node as unknown as FileNodeV1);
|
|
487
|
+
edges.push(...(shardEdges as FileEdgeV1[]));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (issues.some((issue) => issue.kind === "scope_mismatch")) {
|
|
491
|
+
return { snapshot: null, issues };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Sollzahlen gegen das tatsaechlich Geladene. Zweiter, vom Pfadindex
|
|
495
|
+
// unabhaengiger Zeuge: er greift auch dann, wenn der Pfadindex selbst fehlt.
|
|
496
|
+
const expectedNodeCount = countOf(meta["nodeCount"]);
|
|
497
|
+
if (expectedNodeCount !== null && expectedNodeCount !== nodes.length) {
|
|
498
|
+
issues.push({
|
|
499
|
+
kind: "count_mismatch",
|
|
500
|
+
message: `graph_meta/${layout.metaShardId} of generation "${generationId}" declares ${String(expectedNodeCount)} nodes, but ${String(nodes.length)} were loaded`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const expectedEdgeCount = countOf(meta["edgeCount"]);
|
|
504
|
+
if (expectedEdgeCount !== null && expectedEdgeCount !== edges.length) {
|
|
505
|
+
issues.push({
|
|
506
|
+
kind: "count_mismatch",
|
|
507
|
+
message: `graph_meta/${layout.metaShardId} of generation "${generationId}" declares ${String(expectedEdgeCount)} edges, but ${String(edges.length)} were loaded`,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// WP-181: DER DRITTE ZEUGE, der bisher nie befragt wurde.
|
|
512
|
+
//
|
|
513
|
+
// `metrics.nodeCount`/`metrics.edgeCount` liegen im selben Payload und
|
|
514
|
+
// wurden mit demselben Abbild geschrieben — sie sind aber eine EIGENE
|
|
515
|
+
// Aussage, gerechnet vom Metrikpfad, nicht abgeschrieben von
|
|
516
|
+
// `meta.nodeCount`. Wurden nur die aeusseren Zahlen fortgeschrieben und die
|
|
517
|
+
// Metriken nicht (oder umgekehrt), lieferte `readGraphSnapshot` bisher ein
|
|
518
|
+
// Abbild, das sich SELBST widerspricht: `snapshot.metrics.nodeCount` sagt
|
|
519
|
+
// 12, `snapshot.nodes.length` sagt 11 — und beide stehen ohne Vermerk
|
|
520
|
+
// nebeneinander in einem Objekt, das jeder Ableiter fuer in sich stimmig
|
|
521
|
+
// haelt. Ein Widerspruch, den man vorliegen hat und nicht ausspricht, ist
|
|
522
|
+
// dieselbe Klasse wie ein verschwiegener Verlust (E4).
|
|
523
|
+
const metricsPayload = meta["metrics"];
|
|
524
|
+
if (isRecord(metricsPayload)) {
|
|
525
|
+
const metricsNodeCount = countOf(metricsPayload["nodeCount"]);
|
|
526
|
+
if (metricsNodeCount !== null && metricsNodeCount !== nodes.length) {
|
|
527
|
+
issues.push({
|
|
528
|
+
kind: "count_mismatch",
|
|
529
|
+
message: `graph_meta/${layout.metaShardId} of generation "${generationId}" carries metrics.nodeCount ${String(metricsNodeCount)}, but ${String(nodes.length)} nodes were loaded; the returned snapshot would contradict itself`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const metricsEdgeCount = countOf(metricsPayload["edgeCount"]);
|
|
533
|
+
if (metricsEdgeCount !== null && metricsEdgeCount !== edges.length) {
|
|
534
|
+
issues.push({
|
|
535
|
+
kind: "count_mismatch",
|
|
536
|
+
message: `graph_meta/${layout.metaShardId} of generation "${generationId}" carries metrics.edgeCount ${String(metricsEdgeCount)}, but ${String(edges.length)} edges were loaded; the returned snapshot would contradict itself`,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const snapshot: FileGraphSnapshotV1 = {
|
|
542
|
+
schemaVersion: 1,
|
|
543
|
+
workspaceId,
|
|
544
|
+
graphGeneration: generationId,
|
|
545
|
+
nodes: nodes.sort((left, right) => compareCodepoints(left.fileId, right.fileId)),
|
|
546
|
+
edges: edges.sort(compareEdges),
|
|
547
|
+
metrics: meta["metrics"] as FileGraphSnapshotV1["metrics"],
|
|
548
|
+
topology: meta["topology"] as FileGraphSnapshotV1["topology"],
|
|
549
|
+
skipped: meta["skipped"] as FileGraphSnapshotV1["skipped"],
|
|
550
|
+
};
|
|
551
|
+
return { snapshot, issues };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Prueft die Pflichtteile des Metadaten-Shards.
|
|
556
|
+
*
|
|
557
|
+
* Geprueft wird nur die TRAGENDE Form — genau die Zusagen, auf die ein
|
|
558
|
+
* Aufrufer sich ohne weitere Pruefung verlassen darf. Eine vollstaendige
|
|
559
|
+
* Schemapruefung waere hier eine zweite Wahrheit neben `domain`; ein
|
|
560
|
+
* ungeprueftes `as` waere gar keine.
|
|
561
|
+
*/
|
|
562
|
+
function validateMetaPayload(
|
|
563
|
+
meta: Readonly<Record<string, unknown>>,
|
|
564
|
+
scope: GraphSnapshotScope,
|
|
565
|
+
shardId: string,
|
|
566
|
+
storedGenerationId: string,
|
|
567
|
+
): SnapshotReadIssue[] {
|
|
568
|
+
const issues = validatePayloadScope(
|
|
569
|
+
meta,
|
|
570
|
+
scope,
|
|
571
|
+
`graph_meta/${shardId}`,
|
|
572
|
+
storedGenerationId,
|
|
573
|
+
false,
|
|
574
|
+
);
|
|
575
|
+
// WP-193: `String(meta["workspaceId"] ?? "")` machte aus einem FEHLENDEN
|
|
576
|
+
// Workspace den Leerstring — und der Leerstring ist im ganzen Produkt die
|
|
577
|
+
// Grenze, innerhalb derer eine Anfrage ueberhaupt gilt. Ein Abbild ohne
|
|
578
|
+
// Workspace wurde damit zu einem Abbild MIT einem (leeren) Workspace, das
|
|
579
|
+
// sich anschliessend gegen jede echte Workspace-Pruefung falsch verhaelt.
|
|
580
|
+
// Ein Pflichtteil, der fehlt, ist ein Befund, kein Vorgabewert.
|
|
581
|
+
const workspaceId = meta["workspaceId"];
|
|
582
|
+
if (typeof workspaceId !== "string" || workspaceId.trim() === "") {
|
|
583
|
+
issues.push({
|
|
584
|
+
kind: "malformed_payload",
|
|
585
|
+
message: `graph_meta/${shardId} has no usable "workspaceId"; a snapshot without a workspace has no scope it could belong to`,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
const metrics = meta["metrics"];
|
|
589
|
+
if (
|
|
590
|
+
!isRecord(metrics) ||
|
|
591
|
+
typeof metrics["nodeCount"] !== "number" ||
|
|
592
|
+
metrics["workspaceId"] !== scope.workspaceId ||
|
|
593
|
+
metrics["graphGeneration"] !== scope.generationId
|
|
594
|
+
) {
|
|
595
|
+
issues.push({
|
|
596
|
+
kind: "malformed_payload",
|
|
597
|
+
message: `graph_meta/${shardId} has no usable, scope-matching "metrics" payload`,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const topology = meta["topology"];
|
|
601
|
+
if (
|
|
602
|
+
!isRecord(topology) ||
|
|
603
|
+
topology["workspaceId"] !== scope.workspaceId ||
|
|
604
|
+
!Array.isArray(topology["packages"]) ||
|
|
605
|
+
!Array.isArray(topology["services"]) ||
|
|
606
|
+
!isRecord(topology["skipped"]) ||
|
|
607
|
+
topology["skipped"]["generationId"] !== scope.generationId
|
|
608
|
+
) {
|
|
609
|
+
issues.push({
|
|
610
|
+
kind: "malformed_payload",
|
|
611
|
+
message: `graph_meta/${shardId} has no usable, scope-matching "topology" payload`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
const skipped = meta["skipped"];
|
|
615
|
+
if (
|
|
616
|
+
!isRecord(skipped) ||
|
|
617
|
+
skipped["generationId"] !== scope.generationId ||
|
|
618
|
+
!Array.isArray(skipped["entries"]) ||
|
|
619
|
+
!isRecord(skipped["countsByReason"])
|
|
620
|
+
) {
|
|
621
|
+
issues.push({
|
|
622
|
+
kind: "malformed_payload",
|
|
623
|
+
message: `graph_meta/${shardId} has no usable, scope-matching "skipped" payload`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return issues;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function validatePayloadScope(
|
|
630
|
+
payload: Readonly<Record<string, unknown>>,
|
|
631
|
+
scope: GraphSnapshotScope,
|
|
632
|
+
subject: string,
|
|
633
|
+
storedGenerationId: string,
|
|
634
|
+
legacy: boolean,
|
|
635
|
+
): SnapshotReadIssue[] {
|
|
636
|
+
const issues: SnapshotReadIssue[] = [];
|
|
637
|
+
if (storedGenerationId !== scope.generationId) {
|
|
638
|
+
issues.push({
|
|
639
|
+
kind: "scope_mismatch",
|
|
640
|
+
message: `${subject} was returned from generation "${storedGenerationId}" while "${scope.generationId}" was requested`,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const payloadWorkspaceId = payload["workspaceId"];
|
|
645
|
+
if (payloadWorkspaceId !== undefined && payloadWorkspaceId !== scope.workspaceId) {
|
|
646
|
+
issues.push({
|
|
647
|
+
kind: "scope_mismatch",
|
|
648
|
+
message: `${subject} belongs to workspace "${String(payloadWorkspaceId)}", not requested workspace "${scope.workspaceId}"`,
|
|
649
|
+
});
|
|
650
|
+
} else if (!legacy && payloadWorkspaceId === undefined) {
|
|
651
|
+
issues.push({
|
|
652
|
+
kind: "scope_mismatch",
|
|
653
|
+
message: `${subject} has no workspaceId persistence marker`,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const payloadGeneration = payload["graphGeneration"];
|
|
658
|
+
if (payloadGeneration !== undefined && payloadGeneration !== scope.generationId) {
|
|
659
|
+
issues.push({
|
|
660
|
+
kind: "scope_mismatch",
|
|
661
|
+
message: `${subject} belongs to generation "${String(payloadGeneration)}", not requested generation "${scope.generationId}"`,
|
|
662
|
+
});
|
|
663
|
+
} else if (!legacy && payloadGeneration === undefined) {
|
|
664
|
+
issues.push({
|
|
665
|
+
kind: "scope_mismatch",
|
|
666
|
+
message: `${subject} has no graphGeneration persistence marker`,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return issues;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** `file_id`s laut Pfadindex; `null`, wenn der Index unbrauchbar ist. */
|
|
673
|
+
function expectedFileIdsOf(
|
|
674
|
+
payload: Readonly<Record<string, unknown>> | undefined,
|
|
675
|
+
): string[] | null {
|
|
676
|
+
if (payload === undefined) return null;
|
|
677
|
+
const pathIndex = payload["pathIndex"];
|
|
678
|
+
if (!isRecord(pathIndex)) return null;
|
|
679
|
+
const fileIds = new Set<string>();
|
|
680
|
+
for (const value of Object.values(pathIndex)) {
|
|
681
|
+
if (typeof value === "string" && value !== "") fileIds.add(value);
|
|
682
|
+
}
|
|
683
|
+
return [...fileIds].sort(compareCodepoints);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function countOf(value: unknown): number | null {
|
|
687
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
691
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function pathKey(repoId: string, path: string): string {
|
|
695
|
+
return `${repoId}\u001f${normalizePath(path)}`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function assertGraphSnapshotScope(scope: GraphSnapshotScope): void {
|
|
699
|
+
assertIdentifier("workspaceId", scope.workspaceId);
|
|
700
|
+
assertIdentifier("generationId", scope.generationId);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function assertFileGraphSnapshotScope(snapshot: FileGraphSnapshotV1): void {
|
|
704
|
+
assertGraphSnapshotScope({
|
|
705
|
+
workspaceId: snapshot.workspaceId,
|
|
706
|
+
generationId: snapshot.graphGeneration,
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
if (
|
|
710
|
+
snapshot.metrics.workspaceId !== snapshot.workspaceId ||
|
|
711
|
+
snapshot.metrics.graphGeneration !== snapshot.graphGeneration ||
|
|
712
|
+
snapshot.topology.workspaceId !== snapshot.workspaceId ||
|
|
713
|
+
snapshot.topology.skipped.generationId !== snapshot.graphGeneration ||
|
|
714
|
+
snapshot.skipped.generationId !== snapshot.graphGeneration
|
|
715
|
+
) {
|
|
716
|
+
throw new TypeError(
|
|
717
|
+
"file graph metrics, topology and skip ledger must match the snapshot workspace and generation",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
for (const node of snapshot.nodes) {
|
|
721
|
+
if (
|
|
722
|
+
node.workspaceId !== snapshot.workspaceId ||
|
|
723
|
+
node.graphGeneration !== snapshot.graphGeneration
|
|
724
|
+
) {
|
|
725
|
+
throw new TypeError(
|
|
726
|
+
`file graph node "${node.fileId}" does not match snapshot workspace/generation`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
for (const edge of snapshot.edges) {
|
|
731
|
+
if (
|
|
732
|
+
edge.workspaceId !== snapshot.workspaceId ||
|
|
733
|
+
edge.graphGeneration !== snapshot.graphGeneration
|
|
734
|
+
) {
|
|
735
|
+
throw new TypeError(
|
|
736
|
+
`file graph edge "${edge.edgeId}" does not match snapshot workspace/generation`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function assertIdentifier(name: string, value: string): void {
|
|
743
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
744
|
+
throw new TypeError(`${name} must be a non-empty string`);
|
|
745
|
+
}
|
|
746
|
+
}
|