@lde/search-typesense 0.8.0 → 0.9.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 CHANGED
@@ -9,27 +9,87 @@ Typesense search params, runs it, reconstructs the engine-neutral `SearchResult`
9
9
  and manages the search index lifecycle as transactional `@lde/pipeline`
10
10
  writers (Blue/green Rebuild and In-place Rebuild).
11
11
 
12
+ ## Collection naming
13
+
14
+ You pass `SearchType`s; this package names the collections. `deriveCollectionName`
15
+ turns a type’s logical `name` into a Typesense collection name by Typesense’s own
16
+ convention – snake_case, named after the plural of what the collection holds, as
17
+ its [collection guide](https://typesense.org/docs/guide/organizing-collections.html)
18
+ writes them (`people`, `companies`, `blog_articles`):
19
+
20
+ | `SearchType.name` | collection |
21
+ | ----------------- | ---------------- |
22
+ | `Dataset` | `datasets` |
23
+ | `CreativeWork` | `creative_works` |
24
+ | `Person` | `people` |
25
+ | `TVSeries` | `tv_series` |
26
+ | `DCATDataset` | `dcat_datasets` |
27
+
28
+ The writers and the engine all fall back to this one convention, so documents
29
+ cannot be written to `creative_works` and queried from `creativeWorks` – and a
30
+ reference’s `labelSource` resolves to its label type’s collection the same way.
31
+ Every naming input is therefore optional: `buildCollectionDefinition(type)`,
32
+ `new BlueGreenRebuild(client, type)`, `createTypesenseSearchEngine(client, schema)`.
33
+
34
+ Naming lives here rather than in `@lde/search` because it is engine-specific:
35
+ Elasticsearch/OpenSearch index names are kebab-case and
36
+ [constrained](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html)
37
+ (lowercase only, no `\ / * ? " < > | space , #`, no leading `-` `_` `+`, ≤ 255
38
+ bytes), so a future `@lde/search-elasticsearch` formats the same neutral tokens
39
+ (`@lde/search`’s `physicalNameTokens`) its own way. The core stays engine-neutral.
40
+
41
+ Pass an explicit name to **override** the convention – an env prefix, a
42
+ multi-tenant name, a collection that already exists, a migration:
43
+
44
+ ```ts
45
+ const writer = new BlueGreenRebuild(client, CREATIVE_WORK, {
46
+ name: 'staging_creative_works',
47
+ });
48
+ const engine = createTypesenseSearchEngine(client, schema, {
49
+ collections: { CreativeWork: 'staging_creative_works' },
50
+ });
51
+ writer.collectionName; // 'staging_creative_works' – read-only, for logs/health checks
52
+ engine.collectionNameFor(CREATIVE_WORK); // the same name, resolved once at construction
53
+ ```
54
+
55
+ `collections` overrides only the types it names; the rest stay derived.
56
+
57
+ Everything the convention cannot do, it refuses to guess at, when the writer or
58
+ engine is constructed rather than on the first rebuild or search:
59
+
60
+ - a name it cannot spell (`Café` would quietly become `cafs`) or turn into a
61
+ legal collection name;
62
+ - two types whose names **derive to the same collection** – English collapses
63
+ `Medium` and `Media` onto `media`, `Person` and `People` onto `people` – which
64
+ would land both types’ documents in one collection, each search returning the
65
+ other’s. Name either one explicitly to resolve it. Types that share a
66
+ collection _deliberately_ (say, several label sources in one `labels`
67
+ collection) are fine: name both, and it is your call, not an accident.
68
+
12
69
  ## Collection schema and engine
13
70
 
14
- `buildCollectionDefinition(searchType, { name, defaultSortingField, … })` derives a
15
- Typesense collection from the unified `SearchField` model — the Typesense field
71
+ `buildCollectionDefinition(searchType, { name?, defaultSortingField, … })` derives a
72
+ Typesense collection from the unified `SearchField` model – the Typesense field
16
73
  type comes from each field’s `kind`, and the physical fanout (per-locale
17
- search/sort keys) matches what the projection writes, via
18
- `@lde/search`’s `physicalFields`, so the index and the documents cannot drift.
74
+ search/sort keys, plus one regex display field per output text field) matches
75
+ what the projection writes, via `@lde/search`’s `physicalFields` and its display
76
+ helpers, so the index and the documents cannot drift.
19
77
 
20
78
  **Memory lever.** Typesense keeps the index in RAM (with a raw copy of each
21
79
  document on disk), so RAM tracks the _indexed_ surface – roughly 2–3× the size
22
80
  of the fields you search, facet or sort on – not the full document.
23
- `buildCollectionDefinition` keeps that surface minimal: the `output` display labels
24
- fan out to `index: false` fields, stored on disk and fetched only for a hit, so
25
- they cost no RAM; only the folded `*_search_${locale}`, facet/reference and
26
- `*_sort_${locale}` companions are indexed. Keeping retrieval-only fields
27
- un-indexed is the lever for holding a large index’s RAM down.
28
-
29
- `createTypesenseSearchEngine(client, schema, { collections })` is the
81
+ `buildCollectionDefinition` keeps that surface minimal: the `output` display
82
+ labels land in one `index: false` regex field (`${name}_<lang>`, one value per
83
+ present language), stored on disk and fetched only for a hit, so they cost no RAM
84
+ and preserve every language; only the folded `*_search_${locale}`,
85
+ facet/reference and `*_sort_${locale}` companions are indexed. Keeping
86
+ retrieval-only fields un-indexed is the lever for holding a large index’s RAM
87
+ down.
88
+
89
+ `createTypesenseSearchEngine(client, schema, { collections? })` is the
30
90
  `SearchEngine` implementation. Each search:
31
91
 
32
- - validates the query against the search type (the port contract — a
92
+ - validates the query against the search type (the port contract – a
33
93
  structurally invalid query is rejected, never sent);
34
94
  - compiles it into Typesense search params (`buildSearchParams`);
35
95
  - runs the search;
@@ -38,13 +98,13 @@ un-indexed is the lever for holding a large index’s RAM down.
38
98
  sources bundled into a single lookup. A reference without a `labelSource`
39
99
  stays id-only. With `labelCacheTtlMs` set, each label-source collection is
40
100
  instead loaded once into an in-memory cache;
41
- - reconstructs the logical `SearchResult` (`parseSearchResponse`) — language
101
+ - reconstructs the logical `SearchResult` (`parseSearchResponse`) – language
42
102
  maps, labelled references, labelled facet buckets.
43
103
 
44
104
  A label source is just another `SearchType` in the schema (with an `output`,
45
- `searchable` text field called `label`) whose collection appears in
46
- `collections` – a typed entity collection and a ‘labels collection’ are the
47
- same kind of thing.
105
+ `searchable` text field called `label`), so its collection is named by the same
106
+ convention as any other type’s – a typed entity collection and a ‘labels
107
+ collection’ are the same kind of thing.
48
108
 
49
109
  `searchFacets` – the port’s batch entry point – answers a whole batch of
50
110
  facet-only queries (e.g. a faceted listing’s skip-own-filter variants) as a
@@ -95,7 +155,8 @@ const client = new Client({
95
155
  apiKey,
96
156
  });
97
157
 
98
- const writer = new BlueGreenRebuild(client, DATASET, { name: 'datasets' });
158
+ // The collection is named from the type: `Dataset` → `datasets`.
159
+ const writer = new BlueGreenRebuild(client, DATASET);
99
160
  // Standalone use; under @lde/pipeline the Pipeline drives this lifecycle.
100
161
  const run = await writer.openRun(context);
101
162
  await run.write(dataset, documents);
@@ -3,7 +3,8 @@ import type { SearchType } from '@lde/search';
3
3
  import type { RunContext, RunWriter, Writer } from '@lde/pipeline';
4
4
  import { type RebuildOptions } from './rebuild-support.js';
5
5
  /** {@link BlueGreenRebuild} options: the collection-definition options (`name` is
6
- * the logical index name the alias is kept on) plus the rebuild tuning knobs. */
6
+ * the logical index name the alias is kept on – omit it to derive one from the
7
+ * {@link SearchType}) plus the rebuild tuning knobs. */
7
8
  export type BlueGreenRebuildOptions = RebuildOptions;
8
9
  /**
9
10
  * Blue/green Rebuild (build a fresh index alongside the live one, then swap to
@@ -29,16 +30,27 @@ export type BlueGreenRebuildOptions = RebuildOptions;
29
30
  * - `abort` drops the half-built collection and releases the lock; the live
30
31
  * index is untouched.
31
32
  *
32
- * The caller passes only the logical index `name`; the versioned collection
33
- * name and the alias are managed here.
33
+ * The caller passes at most the logical index `name` – omitted, it is derived
34
+ * from the {@link SearchType} ({@link deriveCollectionName}), the same
35
+ * convention the engine reads by; the versioned collection name and the alias
36
+ * are managed here.
34
37
  */
35
38
  export declare class BlueGreenRebuild<TDocument extends {
36
39
  id: string;
37
40
  }> implements Writer<TDocument> {
38
41
  private readonly client;
39
42
  private readonly searchType;
40
- private readonly options;
41
- constructor(client: Client, searchType: SearchType, options: BlueGreenRebuildOptions);
43
+ /**
44
+ * The live Typesense alias this writer keeps pointed at its newest build:
45
+ * the explicit `options.name`, or the name derived from the
46
+ * {@link SearchType}. Read-only and for observability (logging, health
47
+ * checks) – never an input, and the same name
48
+ * {@link createTypesenseSearchEngine} reads the type from. The versioned
49
+ * collections behind it (`${collectionName}_<timestamp>`) stay internal.
50
+ */
51
+ readonly collectionName: string;
52
+ private readonly resolved;
53
+ constructor(client: Client, searchType: SearchType, options?: BlueGreenRebuildOptions);
42
54
  openRun(context: RunContext): Promise<RunWriter<TDocument>>;
43
55
  /** The collection an alias currently points at, or `undefined` if unset. */
44
56
  private aliasTarget;
@@ -1 +1 @@
1
- {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAMvB,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAE9B;kFACkF;AAClF,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,uBAAuB;IAM7C,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA6FjE,4EAA4E;YAC9D,WAAW;CAW1B"}
1
+ {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAMvB,OAAO,EAKL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAa1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAb7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAQjC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA4FjE,4EAA4E;YAC9D,WAAW;CAW1B"}
@@ -27,22 +27,34 @@ import { assertNoReservedFields, deleteByFilter, resolveRebuildOptions, stampDoc
27
27
  * - `abort` drops the half-built collection and releases the lock; the live
28
28
  * index is untouched.
29
29
  *
30
- * The caller passes only the logical index `name`; the versioned collection
31
- * name and the alias are managed here.
30
+ * The caller passes at most the logical index `name` – omitted, it is derived
31
+ * from the {@link SearchType} ({@link deriveCollectionName}), the same
32
+ * convention the engine reads by; the versioned collection name and the alias
33
+ * are managed here.
32
34
  */
33
35
  export class BlueGreenRebuild {
34
36
  client;
35
37
  searchType;
36
- options;
37
- constructor(client, searchType, options) {
38
+ /**
39
+ * The live Typesense alias this writer keeps pointed at its newest build:
40
+ * the explicit `options.name`, or the name derived from the
41
+ * {@link SearchType}. Read-only and for observability (logging, health
42
+ * checks) – never an input, and the same name
43
+ * {@link createTypesenseSearchEngine} reads the type from. The versioned
44
+ * collections behind it (`${collectionName}_<timestamp>`) stay internal.
45
+ */
46
+ collectionName;
47
+ resolved;
48
+ constructor(client, searchType, options = {}) {
38
49
  this.client = client;
39
50
  this.searchType = searchType;
40
- this.options = options;
41
51
  // `source` is stamped on every document for per-dataset rollback.
42
52
  assertNoReservedFields(searchType, [SOURCE_FIELD]);
53
+ this.resolved = resolveRebuildOptions(searchType, options);
54
+ this.collectionName = this.resolved.name;
43
55
  }
44
56
  async openRun(context) {
45
- const { name, batchSize, lockTtlMs, definitionOptions } = resolveRebuildOptions(this.options);
57
+ const { name, batchSize, lockTtlMs, definitionOptions } = this.resolved;
46
58
  return openLockedRun(this.client, name, lockTtlMs, async () => {
47
59
  // Create the fresh (blue) collection up front, so a failure surfaces
48
60
  // before any dataset is processed. startedAt orders the versioned names;
@@ -2,10 +2,12 @@ import type { CollectionCreateSchema } from 'typesense';
2
2
  import { type SearchType } from '@lde/search';
3
3
  /** Deployment-specific options the generic field model does not carry. */
4
4
  export interface CollectionDefinitionOptions {
5
- /** The Typesense collection (or alias) name. */
6
- readonly name: string;
5
+ /** The Typesense collection (or alias) name. Omit to derive it from the
6
+ * type’s `name` ({@link deriveCollectionName}); supply one to override that
7
+ * (an env prefix, a multi-tenant name, an existing collection). */
8
+ readonly name?: string;
7
9
  /** Snowball stemming locale for non-localized searchable fields (e.g. `en`).
8
- * Unset, those fields are not stemmed — folding still applies — so no
10
+ * Unset, those fields are not stemmed – folding still applies – so no
9
11
  * language is ever assumed. Localized text search fields always stem in
10
12
  * their own locale. */
11
13
  readonly defaultLocale?: string;
@@ -26,14 +28,19 @@ export interface CollectionDefinitionOptions {
26
28
  * stems in `defaultLocale` when one is set, and is left unstemmed (folded
27
29
  * only) otherwise.
28
30
  *
31
+ * The collection `name` is optional: omitted, it is derived from the type’s own
32
+ * `name` ({@link deriveCollectionName}), so a caller that has no naming opinion
33
+ * states none.
34
+ *
29
35
  * Memory lever: Typesense holds the index in RAM (with a raw copy of each
30
36
  * document on disk), so RAM tracks the *indexed* surface – roughly 2–3× the
31
37
  * size of the fields you search, facet or sort on – not the whole document.
32
- * This builder keeps that surface minimal: the `output` display labels fan out
33
- * to `index: false` fields, kept on disk and read back only for a hit, so they
34
- * cost no RAM; only the folded `*_search_${locale}`, facet/reference and
35
- * `*_sort_${locale}` companions are indexed. Keeping retrieval-only fields
36
- * un-indexed is the lever for holding a large index’s RAM down.
38
+ * This builder keeps that surface minimal: the `output` display labels land in
39
+ * a single `index: false` regex field (`${name}_<lang>`, one value per present
40
+ * language), kept on disk and read back only for a hit, so they cost no RAM;
41
+ * only the folded `*_search_${locale}`, facet/reference and `*_sort_${locale}`
42
+ * companions are indexed. Keeping retrieval-only fields un-indexed is the lever
43
+ * for holding a large index’s RAM down.
37
44
  */
38
- export declare function buildCollectionDefinition(searchType: SearchType, options: CollectionDefinitionOptions): CollectionCreateSchema;
45
+ export declare function buildCollectionDefinition(searchType: SearchType, options?: CollectionDefinitionOptions): CollectionCreateSchema;
39
46
  //# sourceMappingURL=collection-definition.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAGhE,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,2BAA2B,GACnC,sBAAsB,CAexB"}
1
+ {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAIhE,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;wEAEoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CAexB"}
@@ -1,4 +1,5 @@
1
- import { physicalFields } from '@lde/search/adapter';
1
+ import { displayFieldPattern, physicalFields } from '@lde/search/adapter';
2
+ import { deriveCollectionName } from './collection-name.js';
2
3
  /**
3
4
  * Build a Typesense collection definition from the unified {@link SearchType}, so
4
5
  * the index and the projection are driven by one declarative source and cannot
@@ -11,19 +12,24 @@ import { physicalFields } from '@lde/search/adapter';
11
12
  * stems in `defaultLocale` when one is set, and is left unstemmed (folded
12
13
  * only) otherwise.
13
14
  *
15
+ * The collection `name` is optional: omitted, it is derived from the type’s own
16
+ * `name` ({@link deriveCollectionName}), so a caller that has no naming opinion
17
+ * states none.
18
+ *
14
19
  * Memory lever: Typesense holds the index in RAM (with a raw copy of each
15
20
  * document on disk), so RAM tracks the *indexed* surface – roughly 2–3× the
16
21
  * size of the fields you search, facet or sort on – not the whole document.
17
- * This builder keeps that surface minimal: the `output` display labels fan out
18
- * to `index: false` fields, kept on disk and read back only for a hit, so they
19
- * cost no RAM; only the folded `*_search_${locale}`, facet/reference and
20
- * `*_sort_${locale}` companions are indexed. Keeping retrieval-only fields
21
- * un-indexed is the lever for holding a large index’s RAM down.
22
+ * This builder keeps that surface minimal: the `output` display labels land in
23
+ * a single `index: false` regex field (`${name}_<lang>`, one value per present
24
+ * language), kept on disk and read back only for a hit, so they cost no RAM;
25
+ * only the folded `*_search_${locale}`, facet/reference and `*_sort_${locale}`
26
+ * companions are indexed. Keeping retrieval-only fields un-indexed is the lever
27
+ * for holding a large index’s RAM down.
22
28
  */
23
- export function buildCollectionDefinition(searchType, options) {
29
+ export function buildCollectionDefinition(searchType, options = {}) {
24
30
  const { defaultLocale } = options;
25
31
  const collection = {
26
- name: options.name,
32
+ name: options.name ?? deriveCollectionName(searchType),
27
33
  fields: searchType.fields.flatMap((field) => typesenseFields(field, defaultLocale, options.defaultSortingField)),
28
34
  };
29
35
  if (options.defaultSortingField !== undefined) {
@@ -39,17 +45,24 @@ function typesenseFields(field, defaultLocale, defaultSortingField) {
39
45
  const names = physicalFields(field);
40
46
  if (field.kind === 'text') {
41
47
  const locales = field.locales;
48
+ const displayPattern = displayFieldPattern(field);
42
49
  return [
43
- // Display labels: stored but NOT indexed (`index: false`) – search hits
44
- // the folded `*_search` companions, so the display copy stays on disk and
45
- // off RAM (fetched only for a hit), accents preserved. This is the memory
46
- // lever: RAM tracks the search surface, not the display text.
47
- ...names.display.map((name) => ({
48
- name,
49
- type: 'string',
50
- index: false,
51
- optional: true,
52
- })),
50
+ // Display labels: ONE regex field (`${name}_<lang>`) storing every
51
+ // present language’s value, NOT indexed (`index: false`) – search hits
52
+ // the folded `*_search` companions, so the display copies stay on disk and
53
+ // off RAM (fetched only for a hit), accents preserved, and a language
54
+ // outside `locales` still renders. This is the memory lever: RAM tracks
55
+ // the search surface, not the display text. Absent for a non-output field.
56
+ ...(displayPattern !== undefined
57
+ ? [
58
+ {
59
+ name: displayPattern,
60
+ type: 'string',
61
+ index: false,
62
+ optional: true,
63
+ },
64
+ ]
65
+ : []),
53
66
  // One folded search field per locale, each stemmed in its own
54
67
  // language; the untagged `und` locale is folded but unstemmed unless
55
68
  // the deployment opts in via `defaultLocale`.
@@ -83,7 +96,7 @@ function typesenseFields(field, defaultLocale, defaultSortingField) {
83
96
  },
84
97
  ];
85
98
  // `names.search` is non-empty exactly when the field projects a folded
86
- // search companion — physicalFields owns that rule.
99
+ // search companion – physicalFields owns that rule.
87
100
  for (const name of names.search) {
88
101
  fields.push({
89
102
  name,
@@ -0,0 +1,24 @@
1
+ import type { SearchType } from '@lde/search';
2
+ /**
3
+ * The Typesense collection name for a {@link SearchType}, derived from its
4
+ * logical `name` – the default every writer and the engine fall back to when a
5
+ * deployment supplies none, so the write side and the read side share one
6
+ * convention and cannot drift.
7
+ *
8
+ * The convention is Typesense’s own, as its docs write collection names:
9
+ * snake_case, named after the plural of what the collection holds – `people`,
10
+ * `companies`, `blog_articles`
11
+ * ({@link https://typesense.org/docs/guide/organizing-collections.html |
12
+ * Organizing collections}, which frames a collection as ‘similar to a table in
13
+ * a relational database’). So `CreativeWork` → `creative_works`, `Person` →
14
+ * `people`, `TVSeries` → `tv_series`. The engine-neutral half – the word split
15
+ * and the inflection – is {@link physicalNameTokens}; only the `_` join and the
16
+ * validation below are Typesense’s.
17
+ *
18
+ * A deployment that needs another name (an env prefix, a multi-tenant name, an
19
+ * index that already exists) passes it explicitly instead; the override is
20
+ * never validated here, since an existing collection’s name is the
21
+ * deployment’s business.
22
+ */
23
+ export declare function deriveCollectionName(searchType: SearchType): string;
24
+ //# sourceMappingURL=collection-name.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collection-name.d.ts","sourceRoot":"","sources":["../src/collection-name.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,UAAU,GAAG,MAAM,CAanE"}
@@ -0,0 +1,55 @@
1
+ import { physicalNameTokens } from '@lde/search/adapter';
2
+ /**
3
+ * The Typesense collection name for a {@link SearchType}, derived from its
4
+ * logical `name` – the default every writer and the engine fall back to when a
5
+ * deployment supplies none, so the write side and the read side share one
6
+ * convention and cannot drift.
7
+ *
8
+ * The convention is Typesense’s own, as its docs write collection names:
9
+ * snake_case, named after the plural of what the collection holds – `people`,
10
+ * `companies`, `blog_articles`
11
+ * ({@link https://typesense.org/docs/guide/organizing-collections.html |
12
+ * Organizing collections}, which frames a collection as ‘similar to a table in
13
+ * a relational database’). So `CreativeWork` → `creative_works`, `Person` →
14
+ * `people`, `TVSeries` → `tv_series`. The engine-neutral half – the word split
15
+ * and the inflection – is {@link physicalNameTokens}; only the `_` join and the
16
+ * validation below are Typesense’s.
17
+ *
18
+ * A deployment that needs another name (an env prefix, a multi-tenant name, an
19
+ * index that already exists) passes it explicitly instead; the override is
20
+ * never validated here, since an existing collection’s name is the
21
+ * deployment’s business.
22
+ */
23
+ export function deriveCollectionName(searchType) {
24
+ if (!NAMEABLE_TYPE_NAME.test(searchType.name)) {
25
+ throw new Error(`Cannot derive a Typesense collection name from search type “${searchType.name}”: it carries characters the convention would silently drop rather than name. Rename the type, or pass an explicit name.`);
26
+ }
27
+ const name = physicalNameTokens(searchType).join('_');
28
+ if (!DERIVED_COLLECTION_NAME.test(name)) {
29
+ throw new Error(`Cannot derive a Typesense collection name from search type “${searchType.name}”: it yields “${name}”, which is not a legal collection name. Rename the type, or pass an explicit name.`);
30
+ }
31
+ return name;
32
+ }
33
+ /**
34
+ * A type name this convention can name a collection after without silently
35
+ * losing part of it. {@link physicalNameTokens} matches ASCII words and drops
36
+ * everything else, which is what lets a name already written `creative_work` or
37
+ * `creative-work` tokenize like `CreativeWork` – but it would just as quietly
38
+ * turn `Café` into `cafs` and `Musée` into `mus_es`: legal names for the wrong
39
+ * collection, which is worse than no name at all. So a name is nameable only
40
+ * when it is ASCII words and word separators; anything else is rejected rather
41
+ * than mangled. An explicit name still overrides, since a deployment may
42
+ * legitimately index a type whose name this cannot spell.
43
+ */
44
+ const NAMEABLE_TYPE_NAME = /^[A-Za-z0-9 _-]+$/;
45
+ /**
46
+ * What a *derived* name must look like. Typesense documents no collection-name
47
+ * rules, but a name travels in the URL path of every collection call
48
+ * (`/collections/:name`), so a character with URL meaning breaks that call
49
+ * rather than being rejected at creation – `#` and `+` silently strand a
50
+ * collection ({@link https://github.com/typesense/typesense/issues/548}). This
51
+ * charset is the safe subset {@link physicalNameTokens} can produce anyway, so
52
+ * the check is really a guard on the type name behind it: a `name` with no
53
+ * alphanumerics at all (tokens `[]`, name `''`) is the case that reaches it.
54
+ */
55
+ const DERIVED_COLLECTION_NAME = /^[a-z][a-z0-9_]*$/;
@@ -47,8 +47,16 @@ export declare class InPlaceRebuild<TDocument extends {
47
47
  }> implements Writer<TDocument> {
48
48
  private readonly client;
49
49
  private readonly searchType;
50
- private readonly options;
51
- constructor(client: Client, searchType: SearchType, options: InPlaceRebuildOptions);
50
+ /**
51
+ * The Typesense collection this writer maintains: the explicit
52
+ * `options.name`, or the name derived from the {@link SearchType}. Read-only
53
+ * and for observability (logging, health checks) – never an input, and the
54
+ * same name {@link createTypesenseSearchEngine} reads the type from.
55
+ */
56
+ readonly collectionName: string;
57
+ private readonly maxSweepableSources;
58
+ private readonly resolved;
59
+ constructor(client: Client, searchType: SearchType, options?: InPlaceRebuildOptions);
52
60
  openRun(context: RunContext): Promise<RunWriter<TDocument>>;
53
61
  /**
54
62
  * The distinct sources present in the collection, via a single `source`
@@ -1 +1 @@
1
- {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAYvB,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,qBAAqB;IAK3C,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA0FjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
1
+ {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAYvB,OAAO,EAKL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAY1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAZ7B;;;;;OAKG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAY/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAqFjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
@@ -49,16 +49,26 @@ const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
49
49
  export class InPlaceRebuild {
50
50
  client;
51
51
  searchType;
52
- options;
53
- constructor(client, searchType, options) {
52
+ /**
53
+ * The Typesense collection this writer maintains: the explicit
54
+ * `options.name`, or the name derived from the {@link SearchType}. Read-only
55
+ * and for observability (logging, health checks) – never an input, and the
56
+ * same name {@link createTypesenseSearchEngine} reads the type from.
57
+ */
58
+ collectionName;
59
+ maxSweepableSources;
60
+ resolved;
61
+ constructor(client, searchType, options = {}) {
54
62
  this.client = client;
55
63
  this.searchType = searchType;
56
- this.options = options;
57
64
  assertNoReservedFields(searchType, [SOURCE_FIELD, LAST_SEEN_FIELD]);
65
+ const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = options;
66
+ this.maxSweepableSources = maxSweepableSources;
67
+ this.resolved = resolveRebuildOptions(searchType, rebuildOptions);
68
+ this.collectionName = this.resolved.name;
58
69
  }
59
70
  async openRun(context) {
60
- const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = this.options;
61
- const { name, batchSize, lockTtlMs, definitionOptions } = resolveRebuildOptions(rebuildOptions);
71
+ const { name, batchSize, lockTtlMs, definitionOptions } = this.resolved;
62
72
  return openLockedRun(this.client, name, lockTtlMs, async () => {
63
73
  // Create the collection on demand: SearchType schema + the bookkeeping
64
74
  // fields, `source` faceted so the membership sweep can enumerate the
@@ -101,7 +111,7 @@ export class InPlaceRebuild {
101
111
  },
102
112
  commit: async () => {
103
113
  await importer.flush();
104
- const departed = departedSources(await this.indexedSources(name, maxSweepableSources), context.selectedSources());
114
+ const departed = departedSources(await this.indexedSources(name, this.maxSweepableSources), context.selectedSources());
105
115
  for (const filter of membershipSweepFilters(departed)) {
106
116
  await deleteByFilter(this.client, name, filter);
107
117
  }
package/dist/index.d.ts CHANGED
@@ -6,8 +6,9 @@ export { RebuildAlreadyRunning } from './lock.js';
6
6
  export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
7
7
  export { buildCollectionDefinition } from './collection-definition.js';
8
8
  export type { CollectionDefinitionOptions } from './collection-definition.js';
9
+ export { deriveCollectionName } from './collection-name.js';
9
10
  export { buildSearchParams } from './query-compiler.js';
10
11
  export type { BuildSearchParamsOptions } from './query-compiler.js';
11
12
  export { createTypesenseSearchEngine, parseSearchResponse } from './search.js';
12
- export type { TypesenseSearchEngineOptions, TypesenseSearchResponse, } from './search.js';
13
+ export type { TypesenseSearchEngine, TypesenseSearchEngineOptions, TypesenseSearchResponse, } from './search.js';
13
14
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AACvE,YAAY,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AACvE,YAAY,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,qBAAqB,EACrB,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -3,5 +3,6 @@ export { InPlaceRebuild } from './in-place-rebuild.js';
3
3
  export { RebuildAlreadyRunning } from './lock.js';
4
4
  export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
5
5
  export { buildCollectionDefinition } from './collection-definition.js';
6
+ export { deriveCollectionName } from './collection-name.js';
6
7
  export { buildSearchParams } from './query-compiler.js';
7
8
  export { createTypesenseSearchEngine, parseSearchResponse } from './search.js';
@@ -19,8 +19,17 @@ export interface ResolvedRebuildOptions {
19
19
  readonly lockTtlMs: number;
20
20
  readonly definitionOptions: CollectionDefinitionOptions;
21
21
  }
22
- /** Apply the shared defaults, once, so neither writer restates them. */
23
- export declare function resolveRebuildOptions(options: RebuildOptions): ResolvedRebuildOptions;
22
+ /**
23
+ * Apply the shared defaults, once, so neither writer restates them – including
24
+ * the collection name, derived from the type ({@link deriveCollectionName})
25
+ * when the deployment supplies none. The resolved name is put back into
26
+ * `definitionOptions`, so the collection a writer talks to and the definition
27
+ * it creates are the same one name, resolved once.
28
+ *
29
+ * Writers resolve at construction rather than per run, so an underivable name
30
+ * throws when the writer is built, not on the first rebuild.
31
+ */
32
+ export declare function resolveRebuildOptions(searchType: SearchType, options: RebuildOptions): ResolvedRebuildOptions;
24
33
  /**
25
34
  * Reject a {@link SearchType} that declares any of the bookkeeping field names
26
35
  * a writer stamps, so a domain field can never collide with `source` /
@@ -1 +1 @@
1
- {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAI9E;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,CAAC;CACzD;AAED,wEAAwE;AACxE,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAYxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
1
+ {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAK9E;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,CAAC;CACzD;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAaxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
@@ -1,13 +1,24 @@
1
+ import { deriveCollectionName } from './collection-name.js';
1
2
  import { DEFAULT_LOCK_TTL_MS } from './lock.js';
2
3
  import { DEFAULT_BATCH_SIZE } from './import.js';
3
- /** Apply the shared defaults, once, so neither writer restates them. */
4
- export function resolveRebuildOptions(options) {
4
+ /**
5
+ * Apply the shared defaults, once, so neither writer restates them – including
6
+ * the collection name, derived from the type ({@link deriveCollectionName})
7
+ * when the deployment supplies none. The resolved name is put back into
8
+ * `definitionOptions`, so the collection a writer talks to and the definition
9
+ * it creates are the same one name, resolved once.
10
+ *
11
+ * Writers resolve at construction rather than per run, so an underivable name
12
+ * throws when the writer is built, not on the first rebuild.
13
+ */
14
+ export function resolveRebuildOptions(searchType, options) {
5
15
  const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...definitionOptions } = options;
16
+ const name = definitionOptions.name ?? deriveCollectionName(searchType);
6
17
  return {
7
- name: definitionOptions.name,
18
+ name,
8
19
  batchSize,
9
20
  lockTtlMs,
10
- definitionOptions,
21
+ definitionOptions: { ...definitionOptions, name },
11
22
  };
12
23
  }
13
24
  /**
package/dist/search.d.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  import type { Client } from 'typesense';
2
2
  import { type LocalizedValue, type SearchEngine, type SearchResult, type SearchSchema, type SearchType, type TextField } from '@lde/search';
3
3
  import { type BuildSearchParamsOptions } from './query-compiler.js';
4
- /** Where the engine reads documents — plus every query-compiler knob
4
+ /** Where the engine reads documents – plus every query-compiler knob
5
5
  * ({@link BuildSearchParamsOptions}), declared once there and forwarded
6
- * wholesale into each search. `TypeName` is the union of the schema’s type
7
- * names, so a literal schema makes the `collections` map exhaustive at
8
- * compile time. Reference labels resolve per field from the collection of
9
- * the SearchType its `labelSource` names — a label source is just another
10
- * entry in `collections`. */
6
+ * wholesale into each search. Reference labels resolve per field from the
7
+ * collection of the SearchType its `labelSource` names – a label source is
8
+ * just another type, so it is named the same way. */
11
9
  export interface TypesenseSearchEngineOptions<TypeName extends string = string> extends BuildSearchParamsOptions {
12
- /** The collection (or alias) per search type, keyed by the type’s `name` —
13
- * with a literal schema, omitting a type is a compile error. */
14
- readonly collections: Readonly<Record<TypeName, string>>;
10
+ /**
11
+ * Overrides the collection (or alias) a search type reads, keyed by the
12
+ * type’s `name`. Every type not named here reads the collection derived from
13
+ * its own name ({@link deriveCollectionName}) – the same convention the
14
+ * writers create, so the read side and the write side cannot drift. Supply an
15
+ * entry only where a deployment needs another name (an env prefix, a
16
+ * multi-tenant name, an existing collection); `TypeName` is the union of the
17
+ * schema’s type names, so a typo is still a compile error.
18
+ */
19
+ readonly collections?: Partial<Readonly<Record<TypeName, string>>>;
15
20
  /**
16
21
  * Called when reference-label resolution fails; the search then degrades to
17
- * id-only references rather than failing. Optional — omit to swallow silently.
22
+ * id-only references rather than failing. Optional – omit to swallow silently.
18
23
  */
19
24
  readonly onLabelError?: (error: unknown) => void;
20
25
  /**
@@ -27,6 +32,21 @@ export interface TypesenseSearchEngineOptions<TypeName extends string = string>
27
32
  */
28
33
  readonly labelCacheTtlMs?: number;
29
34
  }
35
+ /**
36
+ * A {@link SearchEngine} backed by Typesense: the port, plus the one engine
37
+ * specific worth exposing – which collection each type actually reads.
38
+ */
39
+ export interface TypesenseSearchEngine<Types extends readonly SearchType[] = readonly SearchType[]> extends SearchEngine<Types> {
40
+ /**
41
+ * The collection `searchType` reads: its {@link
42
+ * TypesenseSearchEngineOptions.collections} override, or the name derived
43
+ * from the type. Resolved at construction and read-only – for observability
44
+ * (logging a search’s target, a health check asserting the collection
45
+ * exists), never an input. Throws for a type outside this engine’s schema,
46
+ * like every other entry point.
47
+ */
48
+ collectionNameFor(searchType: Types[number]): string;
49
+ }
30
50
  /**
31
51
  * One reference field’s resolved label source: the collection it reads, the
32
52
  * source type’s `label` declaration (for reconstructing localized labels) and
@@ -40,12 +60,13 @@ export interface LabelSource {
40
60
  }
41
61
  /**
42
62
  * A Typesense-backed {@link SearchEngine}, bound to the whole
43
- * {@link SearchSchema} at construction — like every other schema consumer.
44
- * Each type’s collection comes from `options.collections`. `search` compiles
45
- * the query ({@link buildSearchParams}), runs it against the type’s
46
- * collection, resolves the reference labels for the page of hits — each
63
+ * {@link SearchSchema} at construction – like every other schema consumer.
64
+ * Each type’s collection is derived from the type ({@link
65
+ * deriveCollectionName}) unless `options.collections` overrides it. `search`
66
+ * compiles the query ({@link buildSearchParams}), runs it against the type’s
67
+ * collection, resolves the reference labels for the page of hits – each
47
68
  * reference field from its own label source’s collection, all sources bundled
48
- * into one lookup — and reconstructs the engine-neutral
69
+ * into one lookup – and reconstructs the engine-neutral
49
70
  * {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
50
71
  * a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) as a
51
72
  * single `multi_search` round-trip with one shared label lookup; a failed
@@ -55,22 +76,23 @@ export interface LabelSource {
55
76
  *
56
77
  * With a schema built by `searchSchema` over `defineSearchType` literals,
57
78
  * `search()` accepts only the deployment’s own types and returns typo-safe
58
- * facet/document keys per call — no caller-side generics.
79
+ * facet/document keys per call – no caller-side generics.
59
80
  */
60
- export declare function createTypesenseSearchEngine<const Types extends readonly SearchType[]>(client: Client, schema: SearchSchema<Types>, options: TypesenseSearchEngineOptions<Types[number]['name']>): SearchEngine<Types>;
81
+ export declare function createTypesenseSearchEngine<const Types extends readonly SearchType[]>(client: Client, schema: SearchSchema<Types>, options?: TypesenseSearchEngineOptions<Types[number]['name']>): TypesenseSearchEngine<Types>;
61
82
  /** One label lookup: a source and the distinct IRIs to resolve against it. */
62
83
  export interface LabelLookupGroup {
63
84
  readonly source: LabelSource;
64
85
  readonly iris: readonly string[];
65
86
  }
66
87
  /**
67
- * Resolve labels from each group’s label-source collection. Localized labels
68
- * are reconstructed from the source type’s `label` declaration; a bare
69
- * untagged `label` value is the `und` fallback when no locale variant exists.
88
+ * Resolve labels from each group’s label-source collection. Labels are
89
+ * reconstructed from the source type’s `label` declaration ({@link
90
+ * localizedValue}), carrying every present language – including ones outside
91
+ * the declared `locales` and untagged (`und`) values.
70
92
  *
71
93
  * All groups travel as ONE `multi_search` (POST) call, each group’s id-list
72
94
  * split over per-search batches: the id-list of a page or facet carrying many
73
- * references — e.g. a dataset with dozens of classes — would overflow
95
+ * references – e.g. a dataset with dozens of classes – would overflow
74
96
  * Typesense’s GET query-string limit (4000 chars, and IRIs URL-encode to
75
97
  * several times their length) if it travelled in the URL. POST puts it in the
76
98
  * body; each batch stays under Typesense’s `per_page` cap, and bundling the
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAGL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAWrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B;;;;;;8BAM8B;AAC9B,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAChC,SAAQ,wBAAwB;IAChC;qEACiE;IACjE,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,EAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,GAC3D,YAAY,CAAC,KAAK,CAAC,CAsRrB;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AA0FD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAgDtC;AAgDD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;KACL,EAAE,CAAC;CACL;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,GAC1C,YAAY,CAsCd"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAGL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAYrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAG7B;;;;sDAIsD;AACtD,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAChC,SAAQ,wBAAwB;IAChC;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IACnE;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB,CACpC,KAAK,SAAS,SAAS,UAAU,EAAE,GAAG,SAAS,UAAU,EAAE,CAC3D,SAAQ,YAAY,CAAC,KAAK,CAAC;IAC3B;;;;;;;OAOG;IACH,iBAAiB,CAAC,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;CACtD;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,GAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAM,GAChE,qBAAqB,CAAC,KAAK,CAAC,CAgS9B;AAyCD,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AA0FD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAgDtC;AA6BD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;KACL,EAAE,CAAC;CACL;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,GAC1C,YAAY,CAsCd"}
package/dist/search.js CHANGED
@@ -1,13 +1,15 @@
1
- import { assertTypeInSchema, assertValidQuery, fieldNamed, isRangeFacet, labelFieldOf, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isRangeFacet, labelFieldOf, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
2
2
  import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
3
+ import { deriveCollectionName } from './collection-name.js';
3
4
  /**
4
5
  * A Typesense-backed {@link SearchEngine}, bound to the whole
5
- * {@link SearchSchema} at construction — like every other schema consumer.
6
- * Each type’s collection comes from `options.collections`. `search` compiles
7
- * the query ({@link buildSearchParams}), runs it against the type’s
8
- * collection, resolves the reference labels for the page of hits — each
6
+ * {@link SearchSchema} at construction – like every other schema consumer.
7
+ * Each type’s collection is derived from the type ({@link
8
+ * deriveCollectionName}) unless `options.collections` overrides it. `search`
9
+ * compiles the query ({@link buildSearchParams}), runs it against the type’s
10
+ * collection, resolves the reference labels for the page of hits – each
9
11
  * reference field from its own label source’s collection, all sources bundled
10
- * into one lookup — and reconstructs the engine-neutral
12
+ * into one lookup – and reconstructs the engine-neutral
11
13
  * {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
12
14
  * a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) as a
13
15
  * single `multi_search` round-trip with one shared label lookup; a failed
@@ -17,22 +19,30 @@ import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
17
19
  *
18
20
  * With a schema built by `searchSchema` over `defineSearchType` literals,
19
21
  * `search()` accepts only the deployment’s own types and returns typo-safe
20
- * facet/document keys per call — no caller-side generics.
22
+ * facet/document keys per call – no caller-side generics.
21
23
  */
22
- export function createTypesenseSearchEngine(client, schema, options) {
23
- // Resolve every type's collection ONCE at construction, so a
24
- // misconfiguration fails at startup — never on the first search that
25
- // happens to hit the unwired type.
26
- const collections = new Map([...schema.values()].map((searchType) => {
27
- const collection = options.collections[searchType.name];
28
- if (collection === undefined) {
29
- throw new Error(`No collection configured for search type “${searchType.name}”; set options.collections.${searchType.name}.`);
30
- }
31
- return [searchType.class, collection];
32
- }));
24
+ export function createTypesenseSearchEngine(client, schema, options = {}) {
25
+ // Resolve every type's collection ONCE at construction – the override when
26
+ // the deployment named one, the derived name otherwise – so an underivable
27
+ // name fails at startup, never on the first search that happens to hit that
28
+ // type.
29
+ const resolved = [...schema.values()].map((searchType) => {
30
+ const override = options.collections?.[searchType.name];
31
+ return {
32
+ searchType,
33
+ collection: override ?? deriveCollectionName(searchType),
34
+ derived: override === undefined,
35
+ };
36
+ });
37
+ assertNoAccidentalSharing(resolved);
38
+ const collections = new Map(resolved.map(({ searchType, collection }) => [
39
+ searchType.class,
40
+ collection,
41
+ ]));
33
42
  // Resolve every reference field's label source ONCE at construction. The
34
43
  // schema already guarantees the named type exists and serves labels
35
- // (`searchSchema`/`labelFieldOf`), and `collections` is exhaustive per type.
44
+ // (`searchSchema`/`labelFieldOf`), and every type resolved a collection
45
+ // above, so both lookups below always hit.
36
46
  const typesByName = new Map([...schema.values()].map((searchType) => [searchType.name, searchType]));
37
47
  const labelSources = new Map([...schema.values()].map((searchType) => [
38
48
  searchType.class,
@@ -156,6 +166,10 @@ export function createTypesenseSearchEngine(client, schema, options) {
156
166
  }
157
167
  const engine = {
158
168
  schema,
169
+ collectionNameFor(searchType) {
170
+ assertTypeInSchema(schema, searchType);
171
+ return collections.get(searchType.class);
172
+ },
159
173
  async search(searchType, query) {
160
174
  // The port contract: a type outside the bound schema and a structurally
161
175
  // invalid query (unknown field, wrong operator, unknown facet) are both
@@ -208,10 +222,40 @@ export function createTypesenseSearchEngine(client, schema, options) {
208
222
  // The runtime object is string-keyed; the literal-schema typing narrows it.
209
223
  return engine;
210
224
  }
225
+ /**
226
+ * Reject two types landing in one collection *by accident*.
227
+ *
228
+ * A schema keeps type names distinct, but distinct names can still derive the
229
+ * same collection – English collapses them (`Medium` and `Media` both give
230
+ * `media`, `Person` and `People` both give `people`). Nothing downstream would
231
+ * complain: both writers would build the one collection, each type’s search
232
+ * would return the other’s documents, and the membership sweep would delete
233
+ * across them. Exactly the drift deriving names is meant to end, so it fails at
234
+ * construction with the override that resolves it.
235
+ *
236
+ * Sharing a collection *deliberately* stays allowed – several label-source
237
+ * types served by one `labels` collection is a reasonable deployment – so this
238
+ * fires only when at least one side was derived, i.e. when nobody chose it.
239
+ */
240
+ function assertNoAccidentalSharing(resolved) {
241
+ const byCollection = new Map();
242
+ for (const entry of resolved) {
243
+ byCollection.set(entry.collection, [
244
+ ...(byCollection.get(entry.collection) ?? []),
245
+ entry,
246
+ ]);
247
+ }
248
+ for (const [collection, entries] of byCollection) {
249
+ if (entries.length > 1 && entries.some((entry) => entry.derived)) {
250
+ const names = entries.map((entry) => `“${entry.searchType.name}”`);
251
+ throw new Error(`Search types ${names.join(' and ')} would share the Typesense collection “${collection}”, which no deployment asked for: their names derive to it. Give ${names.join(' or ')} an explicit collections entry.`);
252
+ }
253
+ }
254
+ }
211
255
  /**
212
256
  * Load a FULL label-source collection into a label map via the documents
213
257
  * export endpoint, which streams every document as JSONL (one JSON object per
214
- * line). Each line is reconstructed by {@link labelValue}, exactly as the
258
+ * line). Each line is reconstructed by {@link localizedValue}, exactly as the
215
259
  * per-search {@link fetchLabels} path does for its `multi_search` hits.
216
260
  */
217
261
  async function loadAllLabels(client, source) {
@@ -225,7 +269,7 @@ async function loadAllLabels(client, source) {
225
269
  continue;
226
270
  }
227
271
  const document = JSON.parse(line);
228
- const label = labelValue(document, source.labelField);
272
+ const label = localizedValue(document, source.labelField);
229
273
  if (label !== undefined) {
230
274
  labels.set(String(document.id), label);
231
275
  }
@@ -286,13 +330,14 @@ function labelLookupGroups(responses, sources, outputSources) {
286
330
  }));
287
331
  }
288
332
  /**
289
- * Resolve labels from each group’s label-source collection. Localized labels
290
- * are reconstructed from the source type’s `label` declaration; a bare
291
- * untagged `label` value is the `und` fallback when no locale variant exists.
333
+ * Resolve labels from each group’s label-source collection. Labels are
334
+ * reconstructed from the source type’s `label` declaration ({@link
335
+ * localizedValue}), carrying every present language – including ones outside
336
+ * the declared `locales` and untagged (`und`) values.
292
337
  *
293
338
  * All groups travel as ONE `multi_search` (POST) call, each group’s id-list
294
339
  * split over per-search batches: the id-list of a page or facet carrying many
295
- * references — e.g. a dataset with dozens of classes — would overflow
340
+ * references – e.g. a dataset with dozens of classes – would overflow
296
341
  * Typesense’s GET query-string limit (4000 chars, and IRIs URL-encode to
297
342
  * several times their length) if it travelled in the URL. POST puts it in the
298
343
  * body; each batch stays under Typesense’s `per_page` cap, and bundling the
@@ -331,7 +376,7 @@ export async function fetchLabels(client, groups, onError) {
331
376
  return;
332
377
  }
333
378
  for (const hit of result.hits ?? []) {
334
- const label = labelValue(hit.document, groupPerSearch[index].source.labelField);
379
+ const label = localizedValue(hit.document, groupPerSearch[index].source.labelField);
335
380
  if (label !== undefined) {
336
381
  labels.set(String(hit.document.id), label);
337
382
  }
@@ -342,21 +387,6 @@ export async function fetchLabels(client, groups, onError) {
342
387
  /** Typesense caps `per_page` at 250; the multi_search POST body holds the
343
388
  * id-list comfortably, so resolve references in batches of this size. */
344
389
  const LABEL_BATCH_SIZE = 200;
345
- /**
346
- * Reconstruct a label document into a language map via the source type’s
347
- * `label` declaration (its per-locale display fields), with a bare untagged
348
- * `label` value as the `und` fallback when no locale variant exists, or
349
- * `undefined` when the document carries no usable label – so the reference
350
- * stays id-only rather than gaining an empty label.
351
- */
352
- function labelValue(document, labelField) {
353
- const localized = localizedValue(document, labelField);
354
- if (localized !== undefined) {
355
- return localized;
356
- }
357
- const untagged = document[labelField.name];
358
- return typeof untagged === 'string' ? { und: [untagged] } : undefined;
359
- }
360
390
  /** Merge per-collection label maps into one IRI-keyed map (URI identity). */
361
391
  function mergeLabels(maps) {
362
392
  if (maps.length === 1) {
@@ -383,7 +413,7 @@ export function parseSearchResponse(response, searchType, labels) {
383
413
  document: reconstructDocument(hit.document, searchType, labels),
384
414
  }));
385
415
  // Reference facets are IRI-keyed; their buckets carry a resolved data label.
386
- // Plain facets (tokens, free strings) carry no label — the consumer owns display.
416
+ // Plain facets (tokens, free strings) carry no label – the consumer owns display.
387
417
  // Only reference facets with a label source get bucket labels; an id-only
388
418
  // reference facet stays id-only even when a cached full-collection map holds
389
419
  // its IRIs.
@@ -447,16 +477,24 @@ function logicalValue(flat, field, labels) {
447
477
  return flat[field.name] === true;
448
478
  }
449
479
  }
450
- /** Gather the per-locale display fields back into a language map. */
480
+ /**
481
+ * Gather every present-language display field back into a language map. Display
482
+ * fields are pattern-based (`${name}_<lang>`, {@link displayLangOf}), so this
483
+ * recovers languages outside the declared `locales` too – a value tagged in an
484
+ * undeclared language, or untagged (`und`), still reconstructs rather than being
485
+ * dropped.
486
+ */
451
487
  function localizedValue(flat, field) {
452
488
  const map = {};
453
- const display = physicalFields(field).display;
454
- field.locales.forEach((locale, index) => {
455
- const value = flat[display[index]];
456
- if (typeof value === 'string') {
457
- map[locale] = [value];
489
+ for (const [key, value] of Object.entries(flat)) {
490
+ if (typeof value !== 'string') {
491
+ continue;
458
492
  }
459
- });
493
+ const lang = displayLangOf(field, key);
494
+ if (lang !== undefined) {
495
+ map[lang] = [value];
496
+ }
497
+ }
460
498
  return Object.keys(map).length > 0 ? map : undefined;
461
499
  }
462
500
  /** Map stored reference IRIs to labelled references; id-only when no label. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -25,7 +25,7 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.6.0",
28
+ "@lde/search": "^0.7.1",
29
29
  "@lde/text-normalization": "^0.1.1",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"