@basaltkit/search 1.3.2 → 1.4.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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { defineIndex, type SearchDocument, type IndexDefinition, type SearchQuery, type SearchHit, type SearchResult, type SearchDriver, } from './types.js';
1
+ export { defineIndex, type SearchDocument, type SearchInput, type IndexDefinition, type SearchQuery, type SearchHit, type SearchResult, type SearchDriver, } from './types.js';
2
2
  export { MemorySearchDriver } from './memory.js';
3
- export { Search, TenantRequiredError, type SearchOptions } from './search.js';
3
+ export { Search, SINGLE_TENANT_SCOPE, TenantRequiredError, type SearchOptions } from './search.js';
4
4
  export { MeilisearchDriver, MeilisearchError, SearchFilterFieldError, SearchIndexNameError, type MeilisearchDriverOptions, } from './drivers/meilisearch.js';
5
5
  export { searchPlugin, syncRule, SEARCH, type SearchPluginOptions, type SyncRule, } from './plugin.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { defineIndex, } from './types.js';
2
2
  export { MemorySearchDriver } from './memory.js';
3
- export { Search, TenantRequiredError } from './search.js';
3
+ export { Search, SINGLE_TENANT_SCOPE, TenantRequiredError } from './search.js';
4
4
  export { MeilisearchDriver, MeilisearchError, SearchFilterFieldError, SearchIndexNameError, } from './drivers/meilisearch.js';
5
5
  export { searchPlugin, syncRule, SEARCH, } from './plugin.js';
package/dist/plugin.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { BasaltHooks } from '@basaltkit/core';
2
2
  import { Search } from './search.js';
3
- import type { IndexDefinition, SearchDocument, SearchDriver } from './types.js';
3
+ import type { IndexDefinition, SearchDriver, SearchInput } from './types.js';
4
4
  export declare const SEARCH: import("@basaltkit/core").Token<Search>;
5
5
  /**
6
6
  * Keeps an index in sync with domain events: on the given hook, either upsert a
@@ -14,10 +14,10 @@ export interface SyncRule<K extends keyof BasaltHooks & string = keyof BasaltHoo
14
14
  hook: K;
15
15
  index: string;
16
16
  /** Build the document to upsert. Return null to skip. */
17
- document?: (payload: BasaltHooks[K]) => SearchDocument | null;
17
+ document?: (payload: BasaltHooks[K]) => SearchInput | null;
18
18
  /** Or the identifiers to remove. Return null to skip. */
19
19
  remove?: (payload: BasaltHooks[K]) => {
20
- tenantId: string;
20
+ tenantId?: string;
21
21
  id: string;
22
22
  } | null;
23
23
  }
package/dist/plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createToken, definePlugin } from '@basaltkit/core';
1
+ import { createToken, definePlugin, ensureMetadata } from '@basaltkit/core';
2
2
  import { MemorySearchDriver } from './memory.js';
3
3
  import { Search } from './search.js';
4
4
  export const SEARCH = createToken('search');
@@ -11,7 +11,10 @@ export function searchPlugin(options = {}) {
11
11
  return definePlugin({
12
12
  name: 'basalt:search',
13
13
  register({ container }) {
14
- container.singleton(SEARCH, () => new Search({ driver }));
14
+ // 'tenancy:active' is tenancyPlugin's marker: how a generic package
15
+ // learns the app is multi-tenant without importing @basaltkit/tenancy.
16
+ const metadata = ensureMetadata(container);
17
+ container.singleton(SEARCH, () => new Search({ driver }, () => metadata.get('tenancy:active').length > 0));
15
18
  },
16
19
  async boot({ container, hooks }) {
17
20
  const search = container.get(SEARCH);
package/dist/search.d.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { BasaltError } from '@basaltkit/core';
2
- import type { SearchDocument, SearchDriver, SearchResult } from './types.js';
2
+ import type { SearchDriver, SearchInput, SearchResult } from './types.js';
3
3
  /** Search was asked for a tenant it couldn't determine. */
4
4
  export declare class TenantRequiredError extends BasaltError {
5
5
  readonly status = 400;
6
6
  constructor();
7
7
  }
8
+ /**
9
+ * The scope every document lands in when the app has no tenancy at all. The
10
+ * driver contract is tenant-keyed, so a single-tenant app still needs one
11
+ * stable key — it just shouldn't have to invent (and remember) it.
12
+ */
13
+ export declare const SINGLE_TENANT_SCOPE = "default";
8
14
  export interface SearchOptions {
9
15
  /** Defaults to the current tenant (`ctx().tenant.id`). */
10
16
  tenantId?: string;
@@ -17,12 +23,37 @@ export interface SearchOptions {
17
23
  * querying takes it from `options.tenantId` or the current request context.
18
24
  */
19
25
  export declare class Search {
26
+ /**
27
+ * Whether the host app registered `@basaltkit/tenancy`. `searchPlugin`
28
+ * wires this to the container's `'tenancy:active'` metadata marker — a
29
+ * signal, not an import, so this generic package never depends on the
30
+ * opt-in SaaS layer. Defaults to `false` (single-tenant).
31
+ */
32
+ private readonly tenancyActive;
20
33
  private readonly driver;
21
34
  constructor(options?: {
22
35
  driver?: SearchDriver;
23
- });
24
- index(indexName: string, document: SearchDocument): Promise<void>;
25
- bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
36
+ },
37
+ /**
38
+ * Whether the host app registered `@basaltkit/tenancy`. `searchPlugin`
39
+ * wires this to the container's `'tenancy:active'` metadata marker — a
40
+ * signal, not an import, so this generic package never depends on the
41
+ * opt-in SaaS layer. Defaults to `false` (single-tenant).
42
+ */
43
+ tenancyActive?: () => boolean);
44
+ index(indexName: string, document: SearchInput): Promise<void>;
45
+ bulk(indexName: string, documents: SearchInput[]): Promise<void>;
26
46
  remove(indexName: string, id: string, tenantId?: string): Promise<void>;
27
47
  search(indexName: string, q: string, options?: SearchOptions): Promise<SearchResult>;
48
+ /**
49
+ * The tenant a call is scoped to.
50
+ *
51
+ * With `@basaltkit/tenancy` registered an unresolvable tenant is an error:
52
+ * indexing or querying unscoped would cross tenants. Without it there is no
53
+ * tenant dimension, so every document shares {@link SINGLE_TENANT_SCOPE} and
54
+ * index/query always agree.
55
+ */
56
+ private tenant;
57
+ /** Fills in the document's tenant with the same rule the read path uses. */
58
+ private resolveDocument;
28
59
  }
package/dist/search.js CHANGED
@@ -7,37 +7,66 @@ export class TenantRequiredError extends BasaltError {
7
7
  super('SEARCH_TENANT_REQUIRED', 'A tenant is required — pass tenantId or run inside a tenant context.');
8
8
  }
9
9
  }
10
- const currentTenant = (explicit) => {
11
- const id = explicit ?? tryCtx()?.['tenant']?.id;
12
- if (!id)
13
- throw new TenantRequiredError();
14
- return id;
15
- };
10
+ /**
11
+ * The scope every document lands in when the app has no tenancy at all. The
12
+ * driver contract is tenant-keyed, so a single-tenant app still needs one
13
+ * stable key — it just shouldn't have to invent (and remember) it.
14
+ */
15
+ export const SINGLE_TENANT_SCOPE = 'default';
16
16
  /**
17
17
  * Tenant-scoped full-text search. Indexing takes the tenant from the document;
18
18
  * querying takes it from `options.tenantId` or the current request context.
19
19
  */
20
20
  export class Search {
21
+ tenancyActive;
21
22
  driver;
22
- constructor(options = {}) {
23
+ constructor(options = {},
24
+ /**
25
+ * Whether the host app registered `@basaltkit/tenancy`. `searchPlugin`
26
+ * wires this to the container's `'tenancy:active'` metadata marker — a
27
+ * signal, not an import, so this generic package never depends on the
28
+ * opt-in SaaS layer. Defaults to `false` (single-tenant).
29
+ */
30
+ tenancyActive = () => false) {
31
+ this.tenancyActive = tenancyActive;
23
32
  this.driver = options.driver ?? new MemorySearchDriver();
24
33
  }
25
34
  index(indexName, document) {
26
- return this.driver.index(indexName, document);
35
+ return this.driver.index(indexName, this.resolveDocument(document));
27
36
  }
28
37
  bulk(indexName, documents) {
29
- return this.driver.bulk(indexName, documents);
38
+ return this.driver.bulk(indexName, documents.map((document) => this.resolveDocument(document)));
30
39
  }
31
40
  async remove(indexName, id, tenantId) {
32
- return this.driver.remove(indexName, currentTenant(tenantId), id);
41
+ return this.driver.remove(indexName, this.tenant(tenantId), id);
33
42
  }
34
43
  async search(indexName, q, options = {}) {
35
44
  return this.driver.search(indexName, {
36
- tenantId: currentTenant(options.tenantId),
45
+ tenantId: this.tenant(options.tenantId),
37
46
  q,
38
47
  ...(options.filters ? { filters: options.filters } : {}),
39
48
  ...(options.limit !== undefined ? { limit: options.limit } : {}),
40
49
  ...(options.offset !== undefined ? { offset: options.offset } : {}),
41
50
  });
42
51
  }
52
+ /**
53
+ * The tenant a call is scoped to.
54
+ *
55
+ * With `@basaltkit/tenancy` registered an unresolvable tenant is an error:
56
+ * indexing or querying unscoped would cross tenants. Without it there is no
57
+ * tenant dimension, so every document shares {@link SINGLE_TENANT_SCOPE} and
58
+ * index/query always agree.
59
+ */
60
+ tenant(explicit) {
61
+ const id = explicit ?? tryCtx()?.['tenant']?.id;
62
+ if (id)
63
+ return id;
64
+ if (this.tenancyActive())
65
+ throw new TenantRequiredError();
66
+ return SINGLE_TENANT_SCOPE;
67
+ }
68
+ /** Fills in the document's tenant with the same rule the read path uses. */
69
+ resolveDocument(document) {
70
+ return { ...document, tenantId: this.tenant(document.tenantId) };
71
+ }
43
72
  }
package/dist/types.d.ts CHANGED
@@ -1,9 +1,23 @@
1
- /** A document to index. Always carries an `id` and the owning `tenantId`. */
1
+ /**
2
+ * A document as the driver sees it: `tenantId` is always resolved by the time
3
+ * it reaches the driver. Callers pass a {@link SearchInput}.
4
+ */
2
5
  export interface SearchDocument {
3
6
  id: string;
4
7
  tenantId: string;
5
8
  [field: string]: unknown;
6
9
  }
10
+ /**
11
+ * A document as callers provide it. `tenantId` is optional: `Search` resolves
12
+ * it from the ambient tenant, and in an app with no `tenancyPlugin` falls back
13
+ * to {@link SINGLE_TENANT_SCOPE} — so a non-SaaS app indexes and queries the
14
+ * same scope without inventing a tenant id.
15
+ */
16
+ export interface SearchInput {
17
+ id: string;
18
+ tenantId?: string;
19
+ [field: string]: unknown;
20
+ }
7
21
  /** Declares an index: which fields are searchable (full-text) and filterable. */
8
22
  export interface IndexDefinition {
9
23
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/search",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },