@lde/search-typesense 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,9 +9,66 @@ 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
71
+ `buildCollectionDefinition(searchType, { name?, defaultSortingField, … })` derives a
15
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
74
  search/sort keys, plus one regex display field per output text field) matches
@@ -29,7 +86,7 @@ facet/reference and `*_sort_${locale}` companions are indexed. Keeping
29
86
  retrieval-only fields un-indexed is the lever for holding a large index’s RAM
30
87
  down.
31
88
 
32
- `createTypesenseSearchEngine(client, schema, { collections })` is the
89
+ `createTypesenseSearchEngine(client, schema, { collections? })` is the
33
90
  `SearchEngine` implementation. Each search:
34
91
 
35
92
  - validates the query against the search type (the port contract – a
@@ -45,9 +102,9 @@ down.
45
102
  maps, labelled references, labelled facet buckets.
46
103
 
47
104
  A label source is just another `SearchType` in the schema (with an `output`,
48
- `searchable` text field called `label`) whose collection appears in
49
- `collections` – a typed entity collection and a ‘labels collection’ are the
50
- 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.
51
108
 
52
109
  `searchFacets` – the port’s batch entry point – answers a whole batch of
53
110
  facet-only queries (e.g. a faceted listing’s skip-own-filter variants) as a
@@ -63,7 +120,8 @@ direct use and testing.
63
120
  Indexing runs through two transactional writers, one per update mode – the
64
121
  [NDE Stack](https://docs.nde.nl/stack/patterns) patterns of the same names:
65
122
 
66
- - [**Blue/green Rebuild**](#bluegreen-rebuild: build a fresh index, then swap to it atomically;
123
+ - [**Blue/green Rebuild**](#bluegreen-rebuild): build a fresh index, then swap to
124
+ it atomically;
67
125
  - [**In-place Rebuild**](#in-place-rebuild): update the live index directly by
68
126
  upserting changed sources and sweeping the rest.
69
127
 
@@ -98,7 +156,8 @@ const client = new Client({
98
156
  apiKey,
99
157
  });
100
158
 
101
- const writer = new BlueGreenRebuild(client, DATASET, { name: 'datasets' });
159
+ // The collection is named from the type: `Dataset` → `datasets`.
160
+ const writer = new BlueGreenRebuild(client, DATASET);
102
161
  // Standalone use; under @lde/pipeline the Pipeline drives this lifecycle.
103
162
  const run = await writer.openRun(context);
104
163
  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;IA6FjE,4EAA4E;YAC9D,WAAW;CAW1B"}
@@ -27,22 +27,35 @@ 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.definitionOptions.name;
43
55
  }
44
56
  async openRun(context) {
45
- const { name, batchSize, lockTtlMs, definitionOptions } = resolveRebuildOptions(this.options);
57
+ const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
58
+ const name = this.collectionName;
46
59
  return openLockedRun(this.client, name, lockTtlMs, async () => {
47
60
  // Create the fresh (blue) collection up front, so a failure surfaces
48
61
  // before any dataset is processed. startedAt orders the versioned names;
@@ -2,8 +2,10 @@ 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
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
@@ -26,6 +28,10 @@ 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.
@@ -36,5 +42,5 @@ export interface CollectionDefinitionOptions {
36
42
  * companions are indexed. Keeping retrieval-only fields un-indexed is the lever
37
43
  * for holding a large index’s RAM down.
38
44
  */
39
- export declare function buildCollectionDefinition(searchType: SearchType, options: CollectionDefinitionOptions): CollectionCreateSchema;
45
+ export declare function buildCollectionDefinition(searchType: SearchType, options?: CollectionDefinitionOptions): CollectionCreateSchema;
40
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;;;;;;;;;;;;;;;;;;;;;GAqBG;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
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,6 +12,10 @@ import { displayFieldPattern, 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.
@@ -21,10 +26,10 @@ import { displayFieldPattern, physicalFields } from '@lde/search/adapter';
21
26
  * companions are indexed. Keeping retrieval-only fields un-indexed is the lever
22
27
  * for holding a large index’s RAM down.
23
28
  */
24
- export function buildCollectionDefinition(searchType, options) {
29
+ export function buildCollectionDefinition(searchType, options = {}) {
25
30
  const { defaultLocale } = options;
26
31
  const collection = {
27
- name: options.name,
32
+ name: options.name ?? deriveCollectionName(searchType),
28
33
  fields: searchType.fields.flatMap((field) => typesenseFields(field, defaultLocale, options.defaultSortingField)),
29
34
  };
30
35
  if (options.defaultSortingField !== undefined) {
@@ -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,CAWnE"}
@@ -0,0 +1,43 @@
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
+ // A name the split cannot spell at all throws in physicalNameTokens, which
25
+ // owns that engine-neutral rule; what is left to check here is whether the
26
+ // formatted result is legal for THIS engine.
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
+ * What a *derived* name must look like. Typesense documents no collection-name
35
+ * rules, but a name travels in the URL path of every collection call
36
+ * (`/collections/:name`), so a character with URL meaning breaks that call
37
+ * rather than being rejected at creation – `#` and `+` silently strand a
38
+ * collection ({@link https://github.com/typesense/typesense/issues/548}).
39
+ * {@link physicalNameTokens} already guarantees lowercase ASCII words, so what
40
+ * this still rules on is the leading character: a type named `123` derives
41
+ * `123s`, and a name should open with a letter rather than read as a number.
42
+ */
43
+ 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;IAsFjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
@@ -49,16 +49,27 @@ 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.definitionOptions.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 { batchSize, lockTtlMs, definitionOptions } = this.resolved;
72
+ const name = this.collectionName;
62
73
  return openLockedRun(this.client, name, lockTtlMs, async () => {
63
74
  // Create the collection on demand: SearchType schema + the bookkeeping
64
75
  // fields, `source` faceted so the membership sweep can enumerate the
@@ -101,7 +112,7 @@ export class InPlaceRebuild {
101
112
  },
102
113
  commit: async () => {
103
114
  await importer.flush();
104
- const departed = departedSources(await this.indexedSources(name, maxSweepableSources), context.selectedSources());
115
+ const departed = departedSources(await this.indexedSources(name, this.maxSweepableSources), context.selectedSources());
105
116
  for (const filter of membershipSweepFilters(departed)) {
106
117
  await deleteByFilter(this.client, name, filter);
107
118
  }
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';
@@ -14,13 +14,24 @@ export interface RebuildOptions extends CollectionDefinitionOptions {
14
14
  /** The shared options resolved to concrete values plus the residual
15
15
  * collection-definition options a writer passes to {@link buildCollectionDefinition}. */
16
16
  export interface ResolvedRebuildOptions {
17
- readonly name: string;
18
17
  readonly batchSize: number;
19
18
  readonly lockTtlMs: number;
20
- readonly definitionOptions: CollectionDefinitionOptions;
19
+ /** The collection-definition options with the `name` resolved – the one place
20
+ * the resolved name lives, so the collection a writer talks to and the
21
+ * definition it creates cannot say different things. */
22
+ readonly definitionOptions: CollectionDefinitionOptions & {
23
+ readonly name: string;
24
+ };
21
25
  }
22
- /** Apply the shared defaults, once, so neither writer restates them. */
23
- export declare function resolveRebuildOptions(options: RebuildOptions): ResolvedRebuildOptions;
26
+ /**
27
+ * Apply the shared defaults, once, so neither writer restates them – including
28
+ * the collection name, derived from the type ({@link deriveCollectionName})
29
+ * when the deployment supplies none.
30
+ *
31
+ * Writers resolve at construction rather than per run, so an underivable name
32
+ * throws when the writer is built, not on the first rebuild.
33
+ */
34
+ export declare function resolveRebuildOptions(searchType: SearchType, options: RebuildOptions): ResolvedRebuildOptions;
24
35
  /**
25
36
  * Reject a {@link SearchType} that declares any of the bookkeeping field names
26
37
  * 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,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;6DAEyD;IACzD,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAcxB;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,23 @@
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.
8
+ *
9
+ * Writers resolve at construction rather than per run, so an underivable name
10
+ * throws when the writer is built, not on the first rebuild.
11
+ */
12
+ export function resolveRebuildOptions(searchType, options) {
5
13
  const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...definitionOptions } = options;
6
14
  return {
7
- name: definitionOptions.name,
8
15
  batchSize,
9
16
  lockTtlMs,
10
- definitionOptions,
17
+ definitionOptions: {
18
+ ...definitionOptions,
19
+ name: definitionOptions.name ?? deriveCollectionName(searchType),
20
+ },
11
21
  };
12
22
  }
13
23
  /**
package/dist/search.d.ts CHANGED
@@ -3,15 +3,20 @@ import { type LocalizedValue, type SearchEngine, type SearchResult, type SearchS
3
3
  import { type BuildSearchParamsOptions } from './query-compiler.js';
4
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
22
  * id-only references rather than failing. Optional – omit to swallow silently.
@@ -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
@@ -41,8 +61,9 @@ export interface LabelSource {
41
61
  /**
42
62
  * A Typesense-backed {@link SearchEngine}, bound to the whole
43
63
  * {@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
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
46
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
69
  * into one lookup – and reconstructs the engine-neutral
@@ -57,7 +78,7 @@ export interface LabelSource {
57
78
  * `search()` accepts only the deployment’s own types and returns typo-safe
58
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;
@@ -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;AAYrB,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;;;;;;;;;;;;;;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"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
package/dist/search.js CHANGED
@@ -1,10 +1,12 @@
1
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
6
  * {@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
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
8
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
12
  * into one lookup – and reconstructs the engine-neutral
@@ -19,20 +21,28 @@ import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
19
21
  * `search()` accepts only the deployment’s own types and returns typo-safe
20
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,6 +222,36 @@ 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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.7.0",
28
+ "@lde/search": "^0.8.0",
29
29
  "@lde/text-normalization": "^0.1.1",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"