@opensaas/stack-rag 0.1.6 → 0.1.7

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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +82 -6
  4. package/dist/config/plugin.d.ts.map +1 -1
  5. package/dist/config/plugin.js +29 -1
  6. package/dist/config/plugin.js.map +1 -1
  7. package/dist/config/types.d.ts +51 -0
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/fields/index.d.ts +1 -0
  10. package/dist/fields/index.d.ts.map +1 -1
  11. package/dist/fields/index.js +1 -0
  12. package/dist/fields/index.js.map +1 -1
  13. package/dist/fields/searchable.d.ts +42 -0
  14. package/dist/fields/searchable.d.ts.map +1 -0
  15. package/dist/fields/searchable.js +51 -0
  16. package/dist/fields/searchable.js.map +1 -0
  17. package/dist/fields/searchable.test.d.ts +2 -0
  18. package/dist/fields/searchable.test.d.ts.map +1 -0
  19. package/dist/fields/searchable.test.js +112 -0
  20. package/dist/fields/searchable.test.js.map +1 -0
  21. package/dist/providers/openai.d.ts +2 -0
  22. package/dist/providers/openai.d.ts.map +1 -1
  23. package/dist/providers/openai.js +35 -20
  24. package/dist/providers/openai.js.map +1 -1
  25. package/dist/runtime/batch.test.js +1 -1
  26. package/dist/storage/access-filter.d.ts +30 -0
  27. package/dist/storage/access-filter.d.ts.map +1 -0
  28. package/dist/storage/access-filter.js +241 -0
  29. package/dist/storage/access-filter.js.map +1 -0
  30. package/dist/storage/index.d.ts +1 -0
  31. package/dist/storage/index.d.ts.map +1 -1
  32. package/dist/storage/index.js +2 -0
  33. package/dist/storage/index.js.map +1 -1
  34. package/dist/storage/pgvector.d.ts.map +1 -1
  35. package/dist/storage/pgvector.js +26 -11
  36. package/dist/storage/pgvector.js.map +1 -1
  37. package/dist/storage/storage.test.js +1 -0
  38. package/dist/storage/storage.test.js.map +1 -1
  39. package/dist/storage/types.d.ts +5 -0
  40. package/dist/storage/types.d.ts.map +1 -1
  41. package/dist/storage/types.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/config/plugin.ts +35 -2
  44. package/src/config/types.ts +59 -0
  45. package/src/fields/index.ts +2 -0
  46. package/src/fields/searchable.test.ts +136 -0
  47. package/src/fields/searchable.ts +57 -0
  48. package/src/providers/openai.ts +37 -22
  49. package/src/runtime/batch.test.ts +1 -1
  50. package/src/storage/access-filter.ts +303 -0
  51. package/src/storage/index.ts +3 -0
  52. package/src/storage/pgvector.ts +31 -11
  53. package/src/storage/storage.test.ts +1 -0
  54. package/src/storage/types.ts +6 -0
  55. package/tsconfig.tsbuildinfo +1 -1
@@ -53,6 +53,11 @@ export type SearchOptions = {
53
53
  * This is merged with access control filters
54
54
  */
55
55
  where?: Record<string, unknown>;
56
+ /**
57
+ * OpenSaaS config for access control integration
58
+ * Required to properly enforce access control in raw SQL queries
59
+ */
60
+ config?: import('@opensaas/stack-core').OpenSaasConfig;
56
61
  };
57
62
  /**
58
63
  * Distance functions for vector similarity
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEtD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;;OAQG;IACH,MAAM,CAAC,CAAC,GAAG,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAE7B;;;;;;;OAOG;IACH,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAAA;CACnD;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;OAGG;IACH,OAAO,EAAE,OAAO,sBAAsB,EAAE,aAAa,CAAA;IAErD;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAChC,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,IAAI,GAAG,eAAe,CAAA;AAEhE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAI1D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAK3D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAS3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAajE"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEtD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;;OAQG;IACH,MAAM,CAAC,CAAC,GAAG,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAE7B;;;;;;;OAOG;IACH,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAAA;CACnD;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;OAGG;IACH,OAAO,EAAE,OAAO,sBAAsB,EAAE,aAAa,CAAA;IAErD;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAE/B;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,sBAAsB,EAAE,cAAc,CAAA;CACvD,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,IAAI,GAAG,eAAe,CAAA;AAEhE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAI1D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAK3D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAS3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAajE"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AA0EA;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,MAAgB;IAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC,CAAA;IAC5E,IAAI,SAAS,KAAK,CAAC;QAAE,OAAO,MAAM,CAAA;IAClC,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,SAAS,CAAC,CAAA;AAC7C,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,CAAW,EAAE,CAAW;IACjD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;AACvD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,CAAW,EAAE,CAAW;IACjD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;QAC9C,MAAM,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;QACvB,OAAO,GAAG,GAAG,IAAI,GAAG,IAAI,CAAA;IAC1B,CAAC,EAAE,CAAC,CAAC,CAAA;IACL,OAAO,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;AAClC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,CAAW,EAAE,CAAW;IACvD,MAAM,OAAO,GAAG,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAChC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC9C,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAE9C,IAAI,UAAU,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,CAAA;IACV,CAAC;IAED,wCAAwC;IACxC,sCAAsC;IACtC,MAAM,UAAU,GAAG,OAAO,GAAG,CAAC,UAAU,GAAG,UAAU,CAAC,CAAA;IACtD,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC,GAAG,CAAC,CAAA;AAC7B,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAgFA;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,MAAgB;IAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC,CAAA;IAC5E,IAAI,SAAS,KAAK,CAAC;QAAE,OAAO,MAAM,CAAA;IAClC,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,SAAS,CAAC,CAAA;AAC7C,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,CAAW,EAAE,CAAW;IACjD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;AACvD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,CAAW,EAAE,CAAW;IACjD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;QAC9C,MAAM,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;QACvB,OAAO,GAAG,GAAG,IAAI,GAAG,IAAI,CAAA;IAC1B,CAAC,EAAE,CAAC,CAAC,CAAA;IACL,OAAO,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;AAClC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,CAAW,EAAE,CAAW;IACvD,MAAM,OAAO,GAAG,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAChC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC9C,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAE9C,IAAI,UAAU,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,CAAA;IACV,CAAC;IAED,wCAAwC;IACxC,sCAAsC;IACtC,MAAM,UAAU,GAAG,OAAO,GAAG,CAAC,UAAU,GAAG,UAAU,CAAC,CAAA;IACtD,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC,GAAG,CAAC,CAAA;AAC7B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-rag",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "RAG and AI embeddings integration for OpenSaas Stack",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "peerDependencies": {
54
54
  "openai": "^6.8.0",
55
- "@opensaas/stack-core": "0.1.6"
55
+ "@opensaas/stack-core": "0.1.7"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "openai": {
@@ -69,7 +69,7 @@
69
69
  "openai": "^6.0.0",
70
70
  "typescript": "^5.9.3",
71
71
  "vitest": "^4.0.0",
72
- "@opensaas/stack-core": "0.1.6"
72
+ "@opensaas/stack-core": "0.1.7"
73
73
  },
74
74
  "scripts": {
75
75
  "build": "tsc",
@@ -1,7 +1,8 @@
1
1
  import type { Plugin } from '@opensaas/stack-core'
2
- import type { RAGConfig, NormalizedRAGConfig } from './types.js'
2
+ import type { RAGConfig, NormalizedRAGConfig, SearchableMetadata } from './types.js'
3
3
  import { normalizeRAGConfig } from './index.js'
4
4
  import { createEmbeddingProvider } from '../providers/index.js'
5
+ import { embedding } from '../fields/embedding.js'
5
6
 
6
7
  /**
7
8
  * RAG plugin for OpenSaas Stack
@@ -45,7 +46,39 @@ export function ragPlugin(config: RAGConfig): Plugin {
45
46
  version: '0.1.0',
46
47
 
47
48
  init: async (context) => {
48
- // Find all embedding fields with autoGenerate enabled
49
+ // First pass: Scan for searchable() wrapped fields and inject embedding fields
50
+ for (const [listName, listConfig] of Object.entries(context.config.lists)) {
51
+ const embeddingFieldsToInject: Record<string, ReturnType<typeof embedding>> = {}
52
+
53
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
54
+ // Check if field has _searchable metadata
55
+ if ('_searchable' in fieldConfig) {
56
+ const meta = fieldConfig._searchable as SearchableMetadata
57
+
58
+ // Determine embedding field name
59
+ const embeddingName = meta.embeddingFieldName || `${fieldName}Embedding`
60
+
61
+ // Create embedding field
62
+ embeddingFieldsToInject[embeddingName] = embedding({
63
+ sourceField: fieldName,
64
+ provider: meta.provider,
65
+ dimensions: meta.dimensions,
66
+ chunking: meta.chunking,
67
+ autoGenerate: true,
68
+ })
69
+ }
70
+ }
71
+
72
+ // Inject all embedding fields at once
73
+ if (Object.keys(embeddingFieldsToInject).length > 0) {
74
+ context.extendList(listName, {
75
+ fields: embeddingFieldsToInject,
76
+ })
77
+ }
78
+ }
79
+
80
+ // Second pass: Find all embedding fields with autoGenerate enabled
81
+ // This includes both manually defined embedding fields AND injected ones from searchable()
49
82
  for (const [listName, listConfig] of Object.entries(context.config.lists)) {
50
83
  for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
51
84
  if (
@@ -281,3 +281,62 @@ export type SearchResult<T = unknown> = {
281
281
  */
282
282
  distance: number
283
283
  }
284
+
285
+ /**
286
+ * Options for searchable() field wrapper
287
+ * Simplified options for common use cases
288
+ */
289
+ export type SearchableOptions = {
290
+ /**
291
+ * Embedding provider to use
292
+ * References a provider name from RAG config
293
+ * Falls back to default provider if not specified
294
+ */
295
+ provider?: EmbeddingProviderName
296
+
297
+ /**
298
+ * Vector dimensions
299
+ * Must match the provider's output dimensions
300
+ * @default 1536 (OpenAI text-embedding-3-small)
301
+ */
302
+ dimensions?: number
303
+
304
+ /**
305
+ * Chunking configuration for long texts
306
+ */
307
+ chunking?: ChunkingConfig
308
+
309
+ /**
310
+ * Custom name for the generated embedding field
311
+ * If not provided, defaults to `${fieldName}Embedding`
312
+ * @example 'contentVector' instead of 'contentEmbedding'
313
+ */
314
+ embeddingFieldName?: string
315
+ }
316
+
317
+ /**
318
+ * Internal metadata attached to searchable fields
319
+ * Used by ragPlugin to identify and inject embedding fields
320
+ * @internal
321
+ */
322
+ export type SearchableMetadata = {
323
+ /**
324
+ * Name for the generated embedding field
325
+ */
326
+ embeddingFieldName: string
327
+
328
+ /**
329
+ * Embedding provider to use
330
+ */
331
+ provider?: EmbeddingProviderName
332
+
333
+ /**
334
+ * Vector dimensions
335
+ */
336
+ dimensions?: number
337
+
338
+ /**
339
+ * Chunking configuration
340
+ */
341
+ chunking?: ChunkingConfig
342
+ }
@@ -4,3 +4,5 @@
4
4
 
5
5
  export { embedding } from './embedding.js'
6
6
  export type { EmbeddingField } from './embedding.js'
7
+
8
+ export { searchable } from './searchable.js'
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { searchable } from './searchable.js'
3
+ import type { BaseFieldConfig } from '@opensaas/stack-core'
4
+ import type { SearchableOptions } from '../config/types.js'
5
+
6
+ // Mock text field for testing
7
+ function mockTextField(): BaseFieldConfig {
8
+ return {
9
+ type: 'text',
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ getZodSchema: () => null as any,
12
+ getPrismaType: () => ({ type: 'String', modifiers: '' }),
13
+ getTypeScriptType: () => ({ type: 'string', optional: false }),
14
+ }
15
+ }
16
+
17
+ describe('searchable() field wrapper', () => {
18
+ it('should preserve original field properties', () => {
19
+ const field = mockTextField()
20
+ const wrapped = searchable(field)
21
+
22
+ expect(wrapped.type).toBe('text')
23
+ expect(wrapped.getZodSchema).toBe(field.getZodSchema)
24
+ expect(wrapped.getPrismaType).toBe(field.getPrismaType)
25
+ expect(wrapped.getTypeScriptType).toBe(field.getTypeScriptType)
26
+ })
27
+
28
+ it('should attach _searchable metadata', () => {
29
+ const field = mockTextField()
30
+ const wrapped = searchable(field, { provider: 'openai' })
31
+
32
+ expect(wrapped._searchable).toBeDefined()
33
+ expect(wrapped._searchable.provider).toBe('openai')
34
+ })
35
+
36
+ it('should use default options when not provided', () => {
37
+ const field = mockTextField()
38
+ const wrapped = searchable(field)
39
+
40
+ expect(wrapped._searchable).toBeDefined()
41
+ expect(wrapped._searchable.embeddingFieldName).toBe('')
42
+ expect(wrapped._searchable.provider).toBeUndefined()
43
+ expect(wrapped._searchable.dimensions).toBeUndefined()
44
+ })
45
+
46
+ it('should accept all searchable options', () => {
47
+ const field = mockTextField()
48
+ const options: SearchableOptions = {
49
+ provider: 'ollama',
50
+ dimensions: 768,
51
+ chunking: {
52
+ strategy: 'sentence',
53
+ maxTokens: 300,
54
+ overlap: 25,
55
+ },
56
+ embeddingFieldName: 'customEmbedding',
57
+ }
58
+
59
+ const wrapped = searchable(field, options)
60
+
61
+ expect(wrapped._searchable.provider).toBe('ollama')
62
+ expect(wrapped._searchable.dimensions).toBe(768)
63
+ expect(wrapped._searchable.chunking).toEqual({
64
+ strategy: 'sentence',
65
+ maxTokens: 300,
66
+ overlap: 25,
67
+ })
68
+ expect(wrapped._searchable.embeddingFieldName).toBe('customEmbedding')
69
+ })
70
+
71
+ it('should preserve field with validation options', () => {
72
+ const fieldWithValidation = {
73
+ ...mockTextField(),
74
+ validation: {
75
+ isRequired: true,
76
+ length: { min: 10, max: 1000 },
77
+ },
78
+ }
79
+
80
+ const wrapped = searchable(fieldWithValidation, { provider: 'openai' })
81
+
82
+ expect(wrapped.validation).toEqual({
83
+ isRequired: true,
84
+ length: { min: 10, max: 1000 },
85
+ })
86
+ expect(wrapped._searchable).toBeDefined()
87
+ })
88
+
89
+ it('should preserve field with hooks', () => {
90
+ const resolveInputHook = () => {}
91
+ const fieldWithHooks = {
92
+ ...mockTextField(),
93
+ hooks: {
94
+ resolveInput: resolveInputHook,
95
+ },
96
+ }
97
+
98
+ const wrapped = searchable(fieldWithHooks)
99
+
100
+ expect(wrapped.hooks).toBeDefined()
101
+ expect(wrapped.hooks?.resolveInput).toBe(resolveInputHook)
102
+ expect(wrapped._searchable).toBeDefined()
103
+ })
104
+
105
+ it('should work with different field types', () => {
106
+ const richTextField = {
107
+ ...mockTextField(),
108
+ type: 'richText' as const,
109
+ }
110
+
111
+ const wrapped = searchable(richTextField, { provider: 'openai' })
112
+
113
+ expect(wrapped.type).toBe('richText')
114
+ expect(wrapped._searchable).toBeDefined()
115
+ })
116
+
117
+ it('should handle empty embeddingFieldName option', () => {
118
+ const field = mockTextField()
119
+ const wrapped = searchable(field, { embeddingFieldName: '' })
120
+
121
+ expect(wrapped._searchable.embeddingFieldName).toBe('')
122
+ })
123
+
124
+ it('should handle partial chunking config', () => {
125
+ const field = mockTextField()
126
+ const wrapped = searchable(field, {
127
+ chunking: {
128
+ strategy: 'recursive',
129
+ },
130
+ })
131
+
132
+ expect(wrapped._searchable.chunking).toEqual({
133
+ strategy: 'recursive',
134
+ })
135
+ })
136
+ })
@@ -0,0 +1,57 @@
1
+ import type { BaseFieldConfig } from '@opensaas/stack-core'
2
+ import type { SearchableOptions, SearchableMetadata } from '../config/types.js'
3
+
4
+ /**
5
+ * High-level field wrapper that automatically adds embedding field and hooks
6
+ *
7
+ * This wrapper makes it easy to add semantic search to any text field by
8
+ * automatically creating a companion embedding field that stays in sync.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { text } from '@opensaas/stack-core/fields'
13
+ * import { searchable } from '@opensaas/stack-rag/fields'
14
+ *
15
+ * fields: {
16
+ * content: searchable(text(), {
17
+ * provider: 'openai',
18
+ * dimensions: 1536
19
+ * })
20
+ * }
21
+ * ```
22
+ *
23
+ * This is equivalent to the manual pattern:
24
+ * ```typescript
25
+ * fields: {
26
+ * content: text(),
27
+ * contentEmbedding: embedding({
28
+ * sourceField: 'content',
29
+ * provider: 'openai',
30
+ * dimensions: 1536,
31
+ * autoGenerate: true
32
+ * })
33
+ * }
34
+ * ```
35
+ *
36
+ * @param field - The field to make searchable (usually text() or richText())
37
+ * @param options - Embedding configuration options
38
+ * @returns The same field with searchable metadata attached
39
+ */
40
+ export function searchable<T extends BaseFieldConfig>(
41
+ field: T,
42
+ options: SearchableOptions = {},
43
+ ): T & { _searchable: SearchableMetadata } {
44
+ const { embeddingFieldName, provider, dimensions, chunking } = options
45
+
46
+ // Attach metadata to the field for ragPlugin to detect
47
+ return {
48
+ ...field,
49
+ _searchable: {
50
+ // Use custom name if provided, otherwise will be set by plugin based on field name
51
+ embeddingFieldName: embeddingFieldName || '',
52
+ provider,
53
+ dimensions,
54
+ chunking,
55
+ },
56
+ }
57
+ }
@@ -10,6 +10,21 @@ const MODEL_DIMENSIONS: Record<OpenAIEmbeddingModel, number> = {
10
10
  'text-embedding-ada-002': 1536,
11
11
  }
12
12
 
13
+ /**
14
+ * Lazily load OpenAI to avoid requiring it at import time
15
+ */
16
+ async function getOpenAI() {
17
+ try {
18
+ const module = await import('openai')
19
+ return module.default
20
+ } catch (error) {
21
+ throw new Error(
22
+ 'OpenAI package not found. Install it with: npm install openai\n' +
23
+ 'Make sure to run: pnpm install openai',
24
+ )
25
+ }
26
+ }
27
+
13
28
  /**
14
29
  * Type for OpenAI client (avoids direct dependency)
15
30
  */
@@ -34,35 +49,33 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
34
49
  readonly model: string
35
50
  readonly dimensions: number
36
51
 
37
- private client: OpenAIClient
52
+ private client: OpenAIClient | null = null
38
53
  private config: OpenAIEmbeddingConfig
54
+ private clientPromise: Promise<OpenAIClient> | null = null
39
55
 
40
56
  constructor(config: OpenAIEmbeddingConfig) {
41
57
  this.config = config
42
58
  this.model = config.model || 'text-embedding-3-small'
43
59
  this.dimensions = MODEL_DIMENSIONS[this.model as OpenAIEmbeddingModel] || 1536
60
+ }
44
61
 
45
- // Initialize OpenAI client
46
- this.client = this.initializeClient()
62
+ private async ensureClient(): Promise<OpenAIClient> {
63
+ if (this.client) return this.client
64
+ if (this.clientPromise) return this.clientPromise
65
+
66
+ this.clientPromise = this.initializeClient()
67
+ this.client = await this.clientPromise
68
+ return this.client
47
69
  }
48
70
 
49
- private initializeClient(): OpenAIClient {
50
- try {
51
- // eslint-disable-next-line @typescript-eslint/no-require-imports
52
- const { OpenAI } = require('openai')
53
-
54
- return new OpenAI({
55
- apiKey: this.config.apiKey,
56
- organization: this.config.organization,
57
- baseURL: this.config.baseURL,
58
- }) as OpenAIClient
59
- } catch (error) {
60
- throw new Error(
61
- 'OpenAI package not found. Install it with: npm install openai\n' +
62
- 'Error: ' +
63
- (error as Error).message,
64
- )
65
- }
71
+ private async initializeClient(): Promise<OpenAIClient> {
72
+ const OpenAI = await getOpenAI()
73
+
74
+ return new OpenAI({
75
+ apiKey: this.config.apiKey,
76
+ organization: this.config.organization,
77
+ baseURL: this.config.baseURL,
78
+ }) as OpenAIClient
66
79
  }
67
80
 
68
81
  /**
@@ -74,7 +87,8 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
74
87
  }
75
88
 
76
89
  try {
77
- const response = await this.client.embeddings.create({
90
+ const client = await this.ensureClient()
91
+ const response = await client.embeddings.create({
78
92
  model: this.model,
79
93
  input: text,
80
94
  encoding_format: 'float',
@@ -111,7 +125,8 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
111
125
 
112
126
  try {
113
127
  // OpenAI supports batch embedding
114
- const response = await this.client.embeddings.create({
128
+ const client = await this.ensureClient()
129
+ const response = await client.embeddings.create({
115
130
  model: this.model,
116
131
  input: validTexts,
117
132
  encoding_format: 'float',
@@ -267,7 +267,7 @@ describe('ProcessingQueue', () => {
267
267
  // With concurrency 3, should be faster than sequential
268
268
  // 5 items with 10ms each sequentially = 50ms
269
269
  // With concurrency 3: ceil(5/3) * 10ms = 20ms
270
- expect(duration).toBeLessThan(40)
270
+ expect(duration).toBeLessThan(50)
271
271
  })
272
272
 
273
273
  it('should track queue size', async () => {