d365fo-mcp 1.10.0 → 1.10.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.
@@ -55,8 +55,10 @@ export declare function discoverLabelFiles(modelDir: string): Promise<Array<{
55
55
  */
56
56
  export declare function indexAllLabels(symbolIndex: XppSymbolIndex, packagesPath: string, modelFilter?: (modelName: string) => boolean, opts?: {
57
57
  ftsStrategy?: 'rebuild' | 'incremental';
58
+ skipFtsRebuild?: boolean;
58
59
  }): Promise<{
59
60
  totalLabels: number;
60
61
  modelsIndexed: number;
62
+ ftsRebuildPending: boolean;
61
63
  }>;
62
64
  //# sourceMappingURL=labelParser.d.ts.map
@@ -15,6 +15,7 @@ import * as fs from 'fs/promises';
15
15
  import * as fsSync from 'fs';
16
16
  import * as path from 'path';
17
17
  import { settingByEnv } from '../config/settings.js';
18
+ import { defaultFileConcurrency, mapWithConcurrency } from '../utils/concurrency.js';
18
19
  /**
19
20
  * Fallback when LABEL_LANGUAGES is unset, taken from the registry rather than
20
21
  * spelled out here. This used to be the literal 'en-US,cs,sk,de' while
@@ -158,15 +159,23 @@ async function indexModelLabels(symbolIndex, modelDir, model, opts) {
158
159
  if (labelFiles.length === 0)
159
160
  return 0;
160
161
  const allEntries = [];
161
- for (const { labelFileId, language, filePath } of labelFiles) {
162
+ // Read the model's label files with a bounded number in flight rather than one at
163
+ // a time. Barely matters on a default en-US build (813 files / ~38 MB across the
164
+ // whole PackagesLocalDirectory), but LABEL_LANGUAGES=all multiplies that by the ~74
165
+ // shipped locales — ~60 K files and a few GB — and a serial await per file makes
166
+ // that phase cost the sum of every read's latency. mapWithConcurrency preserves
167
+ // input order, so the rows still go in deterministically.
168
+ const perFile = await mapWithConcurrency(labelFiles, defaultFileConcurrency(), async ({ labelFileId, language, filePath }) => {
162
169
  let content;
163
170
  try {
164
171
  content = await fs.readFile(filePath, 'utf-8');
165
172
  }
166
173
  catch {
167
- continue;
174
+ return [];
168
175
  }
169
- const labels = parseLabelFile(content, labelFileId, model, language, filePath);
176
+ return parseLabelFile(content, labelFileId, model, language, filePath);
177
+ });
178
+ for (const labels of perFile) {
170
179
  for (const lbl of labels) {
171
180
  allEntries.push({
172
181
  labelId: lbl.labelId,
@@ -203,7 +212,7 @@ export async function indexAllLabels(symbolIndex, packagesPath, modelFilter, opt
203
212
  }
204
213
  catch {
205
214
  console.error(`[LabelParser] Cannot read packages path: ${packagesPath}`);
206
- return { totalLabels, modelsIndexed };
215
+ return { totalLabels, modelsIndexed, ftsRebuildPending: false };
207
216
  }
208
217
  let skippedByFilter = 0;
209
218
  let skippedMissingDir = 0;
@@ -263,9 +272,13 @@ export async function indexAllLabels(symbolIndex, packagesPath, modelFilter, opt
263
272
  }
264
273
  }
265
274
  }
266
- // Rebuild FTS once for all models rather than per-model (avoids O(n^2) rebuild cost).
267
- // Skipped under 'incremental': the triggers already kept labels_fts in sync per row.
268
- if (totalLabels > 0 && !incrementalFts) {
275
+ // The rebuild is O(every label in the database) no matter how narrow this scan was,
276
+ // so a caller looping over several package roots must not pay it once per root —
277
+ // `skipFtsRebuild` lets it hoist the single rebuild out of its own loop and run it
278
+ // when `ftsRebuildPending` comes back true. Skipped under 'incremental' regardless:
279
+ // the triggers already kept labels_fts in sync per row.
280
+ const ftsRebuildPending = totalLabels > 0 && !incrementalFts;
281
+ if (ftsRebuildPending && !opts?.skipFtsRebuild) {
269
282
  symbolIndex.rebuildLabelsFts();
270
283
  }
271
284
  if (modelsIndexed === 0) {
@@ -275,6 +288,10 @@ export async function indexAllLabels(symbolIndex, packagesPath, modelFilter, opt
275
288
  console.log(` - Models with no labels: ${skippedNoLabels}`);
276
289
  console.log(` - Total models found: ${models.length}`);
277
290
  }
278
- return { totalLabels, modelsIndexed };
291
+ return {
292
+ totalLabels,
293
+ modelsIndexed,
294
+ ftsRebuildPending: ftsRebuildPending && !!opts?.skipFtsRebuild,
295
+ };
279
296
  }
280
297
  //# sourceMappingURL=labelParser.js.map
@@ -69,6 +69,42 @@ export declare class XppSymbolIndex {
69
69
  */
70
70
  private rowToSymbol;
71
71
  private initializeDatabase;
72
+ /**
73
+ * The `labels` indexes that exist purely to accelerate reads, keyed by name so
74
+ * they can be dropped for a bulk load and rebuilt afterwards.
75
+ *
76
+ * `idx_labels_unique` is NOT in here — it enforces the dedupe that
77
+ * INSERT OR REPLACE relies on and has to stay live through the load.
78
+ *
79
+ * The two file_path entries are also created on demand by ensureFilePathIndexes()
80
+ * (which adds the large-DB worker dispatch that startup needs); this list is the
81
+ * single definition of their SQL so the two paths cannot drift apart.
82
+ */
83
+ private static readonly LABEL_SECONDARY_INDEXES;
84
+ /** The subset created at schema-init time; the file_path_id index is left to ensureFilePathIndexes(). */
85
+ private static readonly LABEL_SECONDARY_INDEX_SQL;
86
+ /**
87
+ * Drop the read-only `labels` indexes ahead of a bulk load, and report which ones
88
+ * were actually there so the caller can put back exactly that set.
89
+ *
90
+ * Every row of a bulk load otherwise maintains eight B-trees, two of them keyed on
91
+ * a ~130-character absolute path. Measured on a 400 K-row / 150-model reproduction
92
+ * of this exact write path: 17.2 s with the indexes live versus 10.6 s dropping
93
+ * these seven and rebuilding them at the end (the insert itself, 14.9 s → 4.4 s).
94
+ * This is the same trade the symbols side already makes in ensureFilePathIndexes().
95
+ *
96
+ * Build-time only. Do NOT call this on a server that is answering queries — label
97
+ * search degrades to a full scan until createLabelSecondaryIndexes() finishes.
98
+ */
99
+ dropLabelSecondaryIndexes(): string[];
100
+ /**
101
+ * Rebuild the indexes dropped by dropLabelSecondaryIndexes().
102
+ *
103
+ * Pass the array that call returned to restore exactly the set that was there;
104
+ * omit it to create all of them. Returns the elapsed milliseconds so build scripts
105
+ * can report the cost they moved out of the insert loop.
106
+ */
107
+ createLabelSecondaryIndexes(only?: string[]): number;
72
108
  /**
73
109
  * Index `symbols.file_path` and `labels.file_path`.
74
110
  *
@@ -138,6 +174,27 @@ export declare class XppSymbolIndex {
138
174
  * on every database that predates this, so the one-time rebuild is self-triggering
139
175
  * and costs nothing on an already-migrated file.
140
176
  */
177
+ /**
178
+ * Move an existing database from the inline `labels.file_path` column to the
179
+ * `label_files` lookup table.
180
+ *
181
+ * Runs once, gated on `PRAGMA user_version` like the FTS coverage migration below.
182
+ * A database built before this carries the path spelled out on every row; the
183
+ * column cannot simply be dropped, because the rows have to be rewritten to point
184
+ * at the extracted paths instead.
185
+ *
186
+ * This is a full table rewrite, so it is minutes on a multi-GB labels database
187
+ * rather than the ~23 s the FTS migration costs — announced up front for the same
188
+ * reason: a silent stall of that length reads as a hung server. It is worth paying
189
+ * once. The rewritten table is less than half the size, and every full-table
190
+ * operation on it afterwards (FTS rebuild, ANALYZE, VACUUM, any cold-cache scan)
191
+ * moves proportionally less disk.
192
+ *
193
+ * Row ids are carried over deliberately: `labels_fts` is an external-content index
194
+ * keyed on `labels.id`, so preserving them keeps it valid. It is rebuilt at the end
195
+ * anyway, because the content table it points at is a different table object by then.
196
+ */
197
+ private migrateLabelPathsToLabelFiles;
141
198
  private migrateLabelsFtsLanguageCoverage;
142
199
  /**
143
200
  * Add a symbol to the index with enhanced metadata
@@ -600,6 +657,19 @@ export declare class XppSymbolIndex {
600
657
  comment?: string;
601
658
  filePath: string;
602
659
  }): void;
660
+ /**
661
+ * Row id of `filePath` in `label_files`, inserting it if it is new.
662
+ *
663
+ * Memoised for the process: a bulk load calls this once per label but there is
664
+ * one distinct path per .label.txt (813 across a default en-US build of 374 K
665
+ * rows), so without the cache it would be ~374 K index probes to learn 813 answers.
666
+ * The cache is only ever added to — rows in label_files are never deleted, since a
667
+ * path that had labels once may have them again after the next scan and the table
668
+ * is trivially small either way.
669
+ */
670
+ private labelFilePathId;
671
+ /** filePath -> label_files.id, populated lazily by labelFilePathId(). */
672
+ private readonly labelFilePathIds;
603
673
  /**
604
674
  * Bulk-insert labels (drops FTS triggers for speed).
605
675
  * Pass `{ skipFtsRebuild: true }` when indexing many models sequentially;
@@ -672,7 +742,22 @@ export declare class XppSymbolIndex {
672
742
  */
673
743
  private searchLabelsLike;
674
744
  /**
675
- * Get a single label by exact ID (returns all languages)
745
+ * Get a single label by ID (returns all languages).
746
+ *
747
+ * The ID may be spelled any way the rest of the server emits it: a reference
748
+ * (`@ContosoExt:EquipmentName`, `@GLS4170035`, even the doubled
749
+ * `@SYS:@SYS67433`) or the bare key. #888: matching the caller's string
750
+ * against the stored one verbatim made the natural spelling fail for the 27
751
+ * legacy label files, whose keys are stored WITH the sigil — 61% of the
752
+ * indexed rows — and `search` output, which is always a reference, was never
753
+ * valid input here. Both branches of the IN-list still use `idx_labels_id`
754
+ * (and `idx_labels_unique` once the file/model filters are added), so the
755
+ * widening costs nothing; see labelIdSpellings for why the sigil is not
756
+ * normalised away at storage time instead.
757
+ *
758
+ * Rows come back with the id EXACTLY as stored, which is the spelling
759
+ * callers must keep using for anything that reads the .label.txt (see
760
+ * labelMissingOnDisk) or writes a reference.
676
761
  */
677
762
  getLabelById(labelId: string, labelFileId?: string, model?: string): Array<{
678
763
  labelId: string;
@@ -720,8 +805,15 @@ export declare class XppSymbolIndex {
720
805
  getLabelCount(): number;
721
806
  /**
722
807
  * Rename a label ID in the index (used by rename_label tool).
723
- * Updates all rows for the given labelId + labelFileId + model combination
724
- * and rebuilds the FTS index.
808
+ * Updates all rows for the given labelId + labelFileId + model combination.
809
+ *
810
+ * No FTS rebuild: the `labels_au` trigger deletes the old term and inserts the new
811
+ * one for each updated row, which is the whole of the work a rebuild would redo.
812
+ * This used to call rebuildLabelsFts() afterwards — re-tokenising every label in
813
+ * the database (~105 s on the production DB) to reflect a handful of renamed rows,
814
+ * and because node:sqlite is synchronous the server answered nothing for its whole
815
+ * duration. create_label and update_symbol_index were moved off that pattern
816
+ * earlier; this was the last caller still on it.
725
817
  */
726
818
  renameLabelInIndex(oldLabelId: string, newLabelId: string, labelFileId: string, model: string): void;
727
819
  }