@strav/search 0.3.32 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/search",
3
- "version": "0.3.32",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Full-text search for the Strav framework",
6
6
  "license": "MIT",
@@ -18,9 +18,9 @@
18
18
  "tsconfig.json"
19
19
  ],
20
20
  "peerDependencies": {
21
- "@strav/kernel": "0.3.32",
22
- "@strav/database": "0.3.32",
23
- "@strav/cli": "0.3.32"
21
+ "@strav/kernel": "0.4.0",
22
+ "@strav/database": "0.4.0",
23
+ "@strav/cli": "0.4.0"
24
24
  },
25
25
  "scripts": {
26
26
  "test": "bun test tests/",
package/src/helpers.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  SearchResult,
7
7
  IndexSettings,
8
8
  DriverConfig,
9
+ SearchScope,
9
10
  } from './types.ts'
10
11
 
11
12
  /**
@@ -67,4 +68,53 @@ export const search = {
67
68
  deleteIndex(index: string): Promise<void> {
68
69
  return SearchManager.engine().deleteIndex(SearchManager.indexName(index))
69
70
  },
71
+
72
+ /**
73
+ * Return a tenant-scoped wrapper of this helper. All index names
74
+ * resolved through it are namespaced as `${prefix}t${tenantId}_${name}`,
75
+ * giving two tenants on the same shared engine independent indexes.
76
+ *
77
+ * @example
78
+ * await search.for({ tenantId: 42 }).upsert('articles', 1, { … })
79
+ * await search.for({ tenantId: 42 }).query('articles', 'lookup')
80
+ *
81
+ * Apps that don't need multi-tenant isolation skip `.for()` and call
82
+ * the top-level helpers directly.
83
+ */
84
+ for(scope: SearchScope): ScopedSearch {
85
+ return makeScoped(scope)
86
+ },
87
+ }
88
+
89
+ // ── Scoped helper ────────────────────────────────────────────────────────
90
+
91
+ export interface ScopedSearch {
92
+ query(index: string, query: string, options?: SearchOptions): Promise<SearchResult>
93
+ upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void>
94
+ upsertMany(index: string, documents: SearchDocument[]): Promise<void>
95
+ delete(index: string, id: string | number): Promise<void>
96
+ deleteMany(index: string, ids: Array<string | number>): Promise<void>
97
+ flush(index: string): Promise<void>
98
+ createIndex(index: string, options?: IndexSettings): Promise<void>
99
+ deleteIndex(index: string): Promise<void>
100
+ }
101
+
102
+ function makeScoped(scope: SearchScope): ScopedSearch {
103
+ return {
104
+ query: (index, query, options) =>
105
+ SearchManager.engine().search(SearchManager.indexName(index, scope), query, options),
106
+ upsert: (index, id, document) =>
107
+ SearchManager.engine().upsert(SearchManager.indexName(index, scope), id, document),
108
+ upsertMany: (index, documents) =>
109
+ SearchManager.engine().upsertMany(SearchManager.indexName(index, scope), documents),
110
+ delete: (index, id) =>
111
+ SearchManager.engine().delete(SearchManager.indexName(index, scope), id),
112
+ deleteMany: (index, ids) =>
113
+ SearchManager.engine().deleteMany(SearchManager.indexName(index, scope), ids),
114
+ flush: index => SearchManager.engine().flush(SearchManager.indexName(index, scope)),
115
+ createIndex: (index, options) =>
116
+ SearchManager.engine().createIndex(SearchManager.indexName(index, scope), options),
117
+ deleteIndex: index =>
118
+ SearchManager.engine().deleteIndex(SearchManager.indexName(index, scope)),
119
+ }
70
120
  }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export type { SearchableInstance, SearchableModel } from './searchable.ts'
30
30
 
31
31
  // Helper
32
32
  export { search } from './helpers.ts'
33
+ export type { ScopedSearch } from './helpers.ts'
33
34
 
34
35
  // Errors
35
36
  export { SearchError, IndexNotFoundError, SearchQueryError } from './errors.ts'
@@ -43,4 +44,5 @@ export type {
43
44
  SearchResult,
44
45
  SearchHit,
45
46
  IndexSettings,
47
+ SearchScope,
46
48
  } from './types.ts'
@@ -1,6 +1,6 @@
1
1
  import { inject, Configuration, ConfigurationError } from '@strav/kernel'
2
2
  import type { SearchEngine } from './search_engine.ts'
3
- import type { SearchConfig, DriverConfig } from './types.ts'
3
+ import type { SearchConfig, DriverConfig, SearchScope } from './types.ts'
4
4
  import { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
5
5
  import { TypesenseDriver } from './drivers/typesense_driver.ts'
6
6
  import { AlgoliaDriver } from './drivers/algolia_driver.ts'
@@ -53,9 +53,34 @@ export default class SearchManager {
53
53
  return SearchManager._config?.prefix ?? ''
54
54
  }
55
55
 
56
- /** Resolve a full index name by applying the configured prefix. */
57
- static indexName(name: string): string {
58
- return SearchManager.prefix ? `${SearchManager.prefix}${name}` : name
56
+ /**
57
+ * Resolve a full index name by applying the configured prefix and
58
+ * optional per-tenant scope. Per-tenant scoping namespaces the index
59
+ * as `${prefix}t${tenantId}_${name}` so two tenants on the same
60
+ * shared engine cannot read or overwrite each other's documents.
61
+ *
62
+ * Apps that don't need multi-tenant isolation can omit the scope.
63
+ * The driver layer is unchanged — namespacing happens here at the
64
+ * manager boundary.
65
+ */
66
+ static indexName(name: string, scope?: SearchScope | null): string {
67
+ const base = SearchManager.prefix ? `${SearchManager.prefix}${name}` : name
68
+ if (!scope || scope.tenantId === undefined || scope.tenantId === null) return base
69
+ // Validate tenant identifier — anything that ends up in an index
70
+ // name lands in URL paths / SQL identifiers downstream, so refuse
71
+ // values that could escape the namespace. Letters, digits, dashes,
72
+ // underscores only.
73
+ const tenantId = String(scope.tenantId)
74
+ if (!/^[a-zA-Z0-9_-]+$/.test(tenantId)) {
75
+ throw new ConfigurationError(
76
+ `SearchManager.indexName: invalid tenantId ${JSON.stringify(tenantId)} — ` +
77
+ `must match /^[a-zA-Z0-9_-]+$/.`
78
+ )
79
+ }
80
+ if (SearchManager.prefix) {
81
+ return `${SearchManager.prefix}t${tenantId}_${name}`
82
+ }
83
+ return `t${tenantId}_${name}`
59
84
  }
60
85
 
61
86
  /** Register a custom driver factory. */
package/src/types.ts CHANGED
@@ -5,6 +5,21 @@ export interface SearchDocument {
5
5
  [key: string]: unknown
6
6
  }
7
7
 
8
+ // ── Multi-tenant scope ────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Per-tenant scope applied at index-name resolution. Drivers don't see
12
+ * the scope directly — `SearchManager.indexName(name, scope)` rewrites
13
+ * the index name to `${prefix}t${tenantId}_${name}` so two tenants on
14
+ * the same shared engine read independent indexes.
15
+ *
16
+ * The tenantId must match `/^[a-zA-Z0-9_-]+$/`; anything else throws
17
+ * (the value lands in URL paths and SQL identifiers downstream).
18
+ */
19
+ export interface SearchScope {
20
+ tenantId: string | number
21
+ }
22
+
8
23
  // ── Index settings ────────────────────────────────────────────────────────
9
24
 
10
25
  export interface IndexSettings {