@larkup/vector-stores 0.1.19 → 0.1.21

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @larkup/vector-stores
2
+
3
+ ## 0.1.21
4
+
5
+ ### Patch Changes
6
+
7
+ - 5caaf2f: feat: support persistent S3-compatible LanceDB storage for serverless deployments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larkup/vector-stores",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -18,7 +18,7 @@
18
18
  "@pinecone-database/pinecone": "^7.2.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "@larkup/core": "0.1.19"
21
+ "@larkup/core": ">=0.1.20 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "chromadb": "^1.10.5",
@@ -7,42 +7,44 @@
7
7
  */
8
8
 
9
9
  export interface VectorRecord {
10
- id: string
11
- vector: number[]
12
- text: string
13
- title: string
14
- url?: string
15
- source: string
16
- documentId: string
17
- chunkIndex: number
18
- metadata?: Record<string, any>
10
+ id: string;
11
+ vector: number[];
12
+ text: string;
13
+ title: string;
14
+ url?: string;
15
+ source: string;
16
+ documentId: string;
17
+ chunkIndex: number;
18
+ metadata?: Record<string, any>;
19
19
  }
20
20
 
21
21
  export interface QueryHit {
22
- id: string
23
- score: number
24
- text: string
25
- title: string
26
- url?: string
27
- documentId: string
28
- metadata?: Record<string, any>
22
+ id: string;
23
+ score: number;
24
+ text: string;
25
+ title: string;
26
+ url?: string;
27
+ documentId: string;
28
+ metadata?: Record<string, any>;
29
29
  }
30
30
 
31
31
  export interface VectorStoreAdapter {
32
32
  /** Create / connect to the index, sized for `dimensions`. */
33
- init(dimensions: number): Promise<void>
33
+ init(dimensions: number): Promise<void>;
34
34
  /** Remove all existing vectors (full re-index). */
35
- reset(): Promise<void>
35
+ reset(): Promise<void>;
36
36
  /** Upsert a batch of records. */
37
- upsert(records: VectorRecord[]): Promise<void>
37
+ upsert(records: VectorRecord[]): Promise<void>;
38
38
  /** Number of vectors currently stored, if cheaply knowable. */
39
- count(): Promise<number | null>
39
+ count(): Promise<number | null>;
40
40
  /**
41
41
  * Nearest-neighbour search for a query vector.
42
42
  * Pass `queryText` for hybrid/lexical stores so they can also generate a
43
43
  * sparse vector and merge dense + sparse results (RRF).
44
44
  */
45
- query(vector: number[], topK: number, queryText?: string): Promise<QueryHit[]>
45
+ query(vector: number[], topK: number, queryText?: string): Promise<QueryHit[]>;
46
46
  /** Test connection to the vector store. Throws if invalid. */
47
- testConnection(dimensions: number): Promise<void>
47
+ testConnection(dimensions: number): Promise<void>;
48
+ /** Delete all chunks associated with the given document IDs. */
49
+ deleteByDocumentIds(documentIds: string[]): Promise<void>;
48
50
  }
@@ -1,4 +1,4 @@
1
- import type { QueryHit, VectorRecord, VectorStoreAdapter } from "./base";
1
+ import type { QueryHit, VectorRecord, VectorStoreAdapter } from './base';
2
2
 
3
3
  /**
4
4
  * Chroma adapter — self-hosted (client-server) or Chroma Cloud.
@@ -47,7 +47,7 @@ export class ChromaAdapter implements VectorStoreAdapter {
47
47
  private readonly collectionName: string;
48
48
 
49
49
  constructor(private readonly config: ChromaConfig) {
50
- this.collectionName = config.collectionName?.trim() || "documents";
50
+ this.collectionName = config.collectionName?.trim() || 'documents';
51
51
  }
52
52
 
53
53
  /**
@@ -60,37 +60,37 @@ export class ChromaAdapter implements VectorStoreAdapter {
60
60
 
61
61
  let chromadb: any;
62
62
  try {
63
- chromadb = await import("chromadb");
63
+ chromadb = await import('chromadb');
64
64
  } catch {
65
65
  throw new Error(
66
66
  'The "chromadb" package is not installed. Please install it first via the Configure page or run: pnpm add chromadb --filter @larkup/vector-stores',
67
67
  );
68
68
  }
69
69
 
70
- if (this.config.mode === "cloud") {
70
+ if (this.config.mode === 'cloud') {
71
71
  if (!this.config.apiKey) {
72
- throw new Error("Chroma Cloud requires an API key.");
72
+ throw new Error('Chroma Cloud requires an API key.');
73
73
  }
74
74
  const CloudClientClass = chromadb.CloudClient ?? chromadb.default?.CloudClient;
75
75
  if (!CloudClientClass) {
76
- throw new Error("Could not find CloudClient in the chromadb package.");
76
+ throw new Error('Could not find CloudClient in the chromadb package.');
77
77
  }
78
78
  this.client = new CloudClientClass({
79
79
  apiKey: this.config.apiKey,
80
- tenant: this.config.tenant || "default_tenant",
81
- database: this.config.database || "default_database",
80
+ tenant: this.config.tenant || 'default_tenant',
81
+ database: this.config.database || 'default_database',
82
82
  });
83
83
  } else {
84
84
  // Server mode
85
- const host = this.config.host?.trim() || "http://localhost:8000";
85
+ const host = this.config.host?.trim() || 'http://localhost:8000';
86
86
  const ClientClass = chromadb.ChromaClient ?? chromadb.default?.ChromaClient;
87
87
  if (!ClientClass) {
88
- throw new Error("Could not find ChromaClient in the chromadb package.");
88
+ throw new Error('Could not find ChromaClient in the chromadb package.');
89
89
  }
90
90
  const opts: any = { path: host };
91
91
  if (this.config.authToken) {
92
92
  opts.auth = {
93
- provider: "token",
93
+ provider: 'token',
94
94
  credentials: this.config.authToken,
95
95
  };
96
96
  }
@@ -105,7 +105,7 @@ export class ChromaAdapter implements VectorStoreAdapter {
105
105
  const client = await this.getClient();
106
106
  this.collection = await client.getOrCreateCollection({
107
107
  name: this.collectionName,
108
- metadata: { "hnsw:space": "cosine" },
108
+ metadata: { 'hnsw:space': 'cosine' },
109
109
  });
110
110
  return this.collection;
111
111
  }
@@ -126,6 +126,16 @@ export class ChromaAdapter implements VectorStoreAdapter {
126
126
  this.collection = null;
127
127
  }
128
128
 
129
+ async deleteByDocumentIds(documentIds: string[]): Promise<void> {
130
+ if (documentIds.length === 0) return;
131
+ try {
132
+ const collection = await this.getCollection();
133
+ await collection.delete({ where: { documentId: { $in: documentIds } } });
134
+ } catch (err) {
135
+ console.error('Failed to delete vectors by documentIds in Chroma:', err);
136
+ }
137
+ }
138
+
129
139
  // ── Upsert ─────────────────────────────────────────────────────────────────
130
140
 
131
141
  async upsert(records: VectorRecord[]): Promise<void> {
@@ -145,7 +155,6 @@ export class ChromaAdapter implements VectorStoreAdapter {
145
155
  metadatas.push(this.buildMetadata(r));
146
156
  }
147
157
 
148
- // Chroma has a max batch size of ~5000 for upsert, process in chunks
149
158
  const BATCH = 4096;
150
159
  for (let i = 0; i < ids.length; i += BATCH) {
151
160
  await collection.upsert({
@@ -169,7 +178,7 @@ export class ChromaAdapter implements VectorStoreAdapter {
169
178
  if (r.metadata) {
170
179
  for (const [k, v] of Object.entries(r.metadata)) {
171
180
  if (v === null || v === undefined) continue;
172
- if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
181
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
173
182
  meta[k] = v;
174
183
  } else {
175
184
  meta[k] = JSON.stringify(v);
@@ -192,14 +201,10 @@ export class ChromaAdapter implements VectorStoreAdapter {
192
201
 
193
202
  // ── Query ──────────────────────────────────────────────────────────────────
194
203
 
195
- async query(
196
- vector: number[],
197
- topK: number,
198
- queryText?: string,
199
- ): Promise<QueryHit[]> {
204
+ async query(vector: number[], topK: number, queryText?: string): Promise<QueryHit[]> {
200
205
  const collection = await this.getCollection();
201
- const isSemantic = this.config.indexType === "semantic";
202
- const isLexical = this.config.indexType === "lexical";
206
+ const isSemantic = this.config.indexType === 'semantic';
207
+ const isLexical = this.config.indexType === 'lexical';
203
208
 
204
209
  // Semantic only
205
210
  if (isSemantic || !queryText?.trim()) {
@@ -226,15 +231,11 @@ export class ChromaAdapter implements VectorStoreAdapter {
226
231
  return denseHits;
227
232
  }
228
233
 
229
- private async denseQuery(
230
- collection: any,
231
- vector: number[],
232
- topK: number,
233
- ): Promise<QueryHit[]> {
234
+ private async denseQuery(collection: any, vector: number[], topK: number): Promise<QueryHit[]> {
234
235
  const result = await collection.query({
235
236
  queryEmbeddings: [vector],
236
237
  nResults: topK,
237
- include: ["documents", "metadatas", "distances"],
238
+ include: ['documents', 'metadatas', 'distances'],
238
239
  });
239
240
 
240
241
  return this.parseQueryResult(result);
@@ -245,11 +246,10 @@ export class ChromaAdapter implements VectorStoreAdapter {
245
246
  queryText: string,
246
247
  topK: number,
247
248
  ): Promise<QueryHit[]> {
248
- // Chroma supports substring filtering natively via get() with whereDocument
249
249
  const result = await collection.get({
250
- whereDocument: { "$contains": queryText },
250
+ whereDocument: { $contains: queryText },
251
251
  limit: topK,
252
- include: ["documents", "metadatas"],
252
+ include: ['documents', 'metadatas'],
253
253
  });
254
254
 
255
255
  return this.parseGetResult(result);
@@ -266,24 +266,16 @@ export class ChromaAdapter implements VectorStoreAdapter {
266
266
  const meta = (metadatas[i] ?? {}) as Record<string, any>;
267
267
  const score = 1.0; // Exact/lexical matches get max base score before RRF
268
268
 
269
- const {
270
- title,
271
- url,
272
- source,
273
- documentId,
274
- chunkIndex,
275
- ...customMetadata
276
- } = meta;
269
+ const { title, url, source, documentId, chunkIndex, ...customMetadata } = meta;
277
270
 
278
271
  return {
279
272
  id,
280
273
  score,
281
- text: (documents[i] as string) ?? "",
282
- title: (title as string) ?? "Untitled",
274
+ text: (documents[i] as string) ?? '',
275
+ title: (title as string) ?? 'Untitled',
283
276
  url: (url as string) || undefined,
284
- documentId: (documentId as string) ?? "",
285
- metadata:
286
- Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
277
+ documentId: (documentId as string) ?? '',
278
+ metadata: Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
287
279
  };
288
280
  });
289
281
  }
@@ -299,28 +291,19 @@ export class ChromaAdapter implements VectorStoreAdapter {
299
291
  return ids.map((id, i) => {
300
292
  const meta = (metadatas[i] ?? {}) as Record<string, any>;
301
293
  const distance = distances[i] ?? 0;
302
- // Chroma cosine distance: 0 = identical, 2 = opposite
303
294
  // Convert to a similarity score
304
295
  const score = 1 / (1 + distance);
305
296
 
306
- const {
307
- title,
308
- url,
309
- source,
310
- documentId,
311
- chunkIndex,
312
- ...customMetadata
313
- } = meta;
297
+ const { title, url, source, documentId, chunkIndex, ...customMetadata } = meta;
314
298
 
315
299
  return {
316
300
  id,
317
301
  score,
318
- text: (documents[i] as string) ?? "",
319
- title: (title as string) ?? "Untitled",
302
+ text: (documents[i] as string) ?? '',
303
+ title: (title as string) ?? 'Untitled',
320
304
  url: (url as string) || undefined,
321
- documentId: (documentId as string) ?? "",
322
- metadata:
323
- Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
305
+ documentId: (documentId as string) ?? '',
306
+ metadata: Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
324
307
  };
325
308
  });
326
309
  }
@@ -339,29 +322,26 @@ export class ChromaAdapter implements VectorStoreAdapter {
339
322
  try {
340
323
  await client.heartbeat();
341
324
  } catch (err: any) {
342
- const msg: string = err.message ?? "";
325
+ const msg: string = err.message ?? '';
343
326
 
344
327
  if (
345
- msg.includes("401") ||
346
- msg.includes("Unauthorized") ||
347
- msg.includes("403") ||
348
- msg.includes("Forbidden")
328
+ msg.includes('401') ||
329
+ msg.includes('Unauthorized') ||
330
+ msg.includes('403') ||
331
+ msg.includes('Forbidden')
349
332
  ) {
350
- throw new Error(
351
- "Invalid Chroma credentials. Please check your auth token or API key.",
352
- );
333
+ throw new Error('Invalid Chroma credentials. Please check your auth token or API key.');
353
334
  }
354
335
 
355
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
336
+ if (msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) {
356
337
  throw new Error(
357
- "Could not reach the Chroma server. Is it running and accessible at the configured URL?",
338
+ 'Could not reach the Chroma server. Is it running and accessible at the configured URL?',
358
339
  );
359
340
  }
360
341
 
361
342
  throw new Error(`Failed to reach Chroma server: ${msg}`);
362
343
  }
363
344
 
364
- // Verify we can access the specified tenant/database by listing collections
365
345
  try {
366
346
  await client.listCollections();
367
347
  } catch (err: any) {
@@ -374,11 +354,7 @@ export class ChromaAdapter implements VectorStoreAdapter {
374
354
 
375
355
  // ── Helpers ──────────────────────────────────────────────────────────────────
376
356
 
377
- function rrfMerge(
378
- denseHits: QueryHit[],
379
- sparseHits: QueryHit[],
380
- topK: number,
381
- ): QueryHit[] {
357
+ function rrfMerge(denseHits: QueryHit[], sparseHits: QueryHit[], topK: number): QueryHit[] {
382
358
  const scores = new Map<string, { hit: QueryHit; score: number }>();
383
359
 
384
360
  const addList = (hits: QueryHit[]) =>
@@ -1,10 +1,11 @@
1
- import path from "node:path";
2
- import fs from "node:fs/promises";
3
- import * as lancedb from "@lancedb/lancedb";
4
- import type { QueryHit, VectorRecord, VectorStoreAdapter } from "./base";
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import * as lancedb from '@lancedb/lancedb';
4
+ import type { QueryHit, VectorRecord, VectorStoreAdapter } from './base';
5
5
 
6
6
  /**
7
- * LanceDB adapter — embedded/on-disk for local dev, or LanceDB Cloud.
7
+ * LanceDB adapter — embedded/on-disk for local dev, LanceDB Cloud, or an
8
+ * S3-compatible object store (including Cloudflare R2).
8
9
  *
9
10
  * The table schema is inferred from the first batch of rows we add, so `init`
10
11
  * is a no-op connect; the table is (re)created lazily on `reset`/first upsert.
@@ -15,6 +16,11 @@ interface LanceConfig {
15
16
  dbPath?: string;
16
17
  uri?: string;
17
18
  apiKey?: string;
19
+ s3Uri?: string;
20
+ s3Endpoint?: string;
21
+ s3Region?: string;
22
+ s3AccessKeyId?: string;
23
+ s3SecretAccessKey?: string;
18
24
  tableName?: string;
19
25
  }
20
26
 
@@ -36,34 +42,49 @@ export class LanceDBAdapter implements VectorStoreAdapter {
36
42
  private readonly tableName: string;
37
43
 
38
44
  constructor(private readonly config: LanceConfig) {
39
- this.tableName = config.tableName?.trim() || "documents";
45
+ this.tableName = config.tableName?.trim() || 'documents';
40
46
  }
41
47
 
42
48
  private async connect(): Promise<lancedb.Connection> {
43
49
  if (this.conn) return this.conn;
44
- if (this.config.mode === "cloud") {
50
+ if (this.config.mode === 'cloud') {
45
51
  if (!this.config.uri || !this.config.apiKey) {
46
- throw new Error("LanceDB Cloud requires both a URI and an API key.");
52
+ throw new Error('LanceDB Cloud requires both a URI and an API key.');
47
53
  }
48
- if (!this.config.uri.startsWith("db://")) {
49
- throw new Error(
50
- "LanceDB Cloud URI must start with 'db://' (e.g. db://my-database).",
51
- );
54
+ if (!this.config.uri.startsWith('db://')) {
55
+ throw new Error("LanceDB Cloud URI must start with 'db://' (e.g. db://my-database).");
52
56
  }
53
57
  this.conn = await lancedb.connect(this.config.uri, {
54
58
  apiKey: this.config.apiKey,
55
59
  });
60
+ } else if (this.config.mode === 's3') {
61
+ if (!this.config.s3Uri || !this.config.s3AccessKeyId || !this.config.s3SecretAccessKey) {
62
+ throw new Error(
63
+ 'S3-compatible LanceDB requires a URI, access key ID, and secret access key.',
64
+ );
65
+ }
66
+ if (!this.config.s3Uri.startsWith('s3://')) {
67
+ throw new Error(
68
+ "S3-compatible LanceDB URI must start with 's3://' (e.g. s3://my-bucket/lancedb).",
69
+ );
70
+ }
71
+ this.conn = await lancedb.connect(this.config.s3Uri, {
72
+ storageOptions: {
73
+ aws_access_key_id: this.config.s3AccessKeyId,
74
+ aws_secret_access_key: this.config.s3SecretAccessKey,
75
+ ...(this.config.s3Endpoint ? { aws_endpoint: this.config.s3Endpoint } : {}),
76
+ ...(this.config.s3Region ? { aws_region: this.config.s3Region } : {}),
77
+ },
78
+ });
56
79
  } else {
57
- const dir = this.config.dbPath?.trim() || "./.larkup/lancedb";
80
+ const dir = this.config.dbPath?.trim() || './.larkup/lancedb';
58
81
  let abs = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
59
82
 
60
83
  try {
61
84
  await fs.mkdir(abs, { recursive: true });
62
85
  } catch (err: any) {
63
86
  if (path.isAbsolute(dir)) {
64
- // The user likely forgot the dot (e.g. typed "/lancedb" instead of "./lancedb").
65
- // Automatically retry by prepending "." to make it relative to the workspace.
66
- const fallbackDir = "." + dir;
87
+ const fallbackDir = '.' + dir;
67
88
  abs = path.join(process.cwd(), fallbackDir);
68
89
  try {
69
90
  await fs.mkdir(abs, { recursive: true });
@@ -73,9 +94,7 @@ export class LanceDBAdapter implements VectorStoreAdapter {
73
94
  );
74
95
  }
75
96
  } else {
76
- throw new Error(
77
- `Could not create directory for LanceDB at "${abs}": ${err.message}`,
78
- );
97
+ throw new Error(`Could not create directory for LanceDB at "${abs}": ${err.message}`);
79
98
  }
80
99
  }
81
100
 
@@ -101,13 +120,23 @@ export class LanceDBAdapter implements VectorStoreAdapter {
101
120
  this.table = null;
102
121
  }
103
122
 
123
+ async deleteByDocumentIds(documentIds: string[]): Promise<void> {
124
+ if (documentIds.length === 0) return;
125
+ if (!this.table) {
126
+ await this.init();
127
+ if (!this.table) return;
128
+ }
129
+ const idsString = documentIds.map((id) => `'${id}'`).join(', ');
130
+ await this.table.delete(`documentId IN (${idsString})`);
131
+ }
132
+
104
133
  private toRow(r: VectorRecord): LanceRow {
105
134
  return {
106
135
  id: r.id,
107
136
  vector: r.vector,
108
137
  text: r.text,
109
138
  title: r.title,
110
- url: r.url ?? "",
139
+ url: r.url ?? '',
111
140
  source: r.source,
112
141
  documentId: r.documentId,
113
142
  chunkIndex: r.chunkIndex,
@@ -151,37 +180,33 @@ export class LanceDBAdapter implements VectorStoreAdapter {
151
180
  await this.init();
152
181
  if (!this.table) return [];
153
182
  }
154
- const rows = (await this.table
155
- .search(vector)
156
- .limit(topK)
157
- .toArray()) as Array<LanceRow & { _distance?: number }>;
183
+ const rows = (await this.table.search(vector).limit(topK).toArray()) as Array<
184
+ LanceRow & { _distance?: number }
185
+ >;
158
186
 
159
187
  return rows.map((row) => ({
160
188
  id: row.id as string,
161
- // Convert L2 distance a 0..1 similarity-ish score for display.
162
- score: typeof row._distance === "number" ? 1 / (1 + row._distance) : 0,
189
+ score: typeof row._distance === 'number' ? 1 / (1 + row._distance) : 0,
163
190
  text: row.text as string,
164
191
  title: row.title as string,
165
192
  url: (row.url as string) || undefined,
166
193
  documentId: row.documentId as string,
167
- metadata:
168
- typeof row.metadata === "string" ? JSON.parse(row.metadata) : undefined,
194
+ metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata) : undefined,
169
195
  }));
170
196
  }
171
197
 
172
198
  async testConnection(dimensions: number): Promise<void> {
173
- // ── Local mode: just verify the directory is reachable ──────────────────
174
- if (this.config.mode !== "cloud") {
199
+ if (this.config.mode !== 'cloud') {
175
200
  try {
176
201
  await this.connect();
177
202
  return;
178
203
  } catch (err: any) {
179
- throw new Error(`Failed to connect to local LanceDB: ${err.message}`);
204
+ const label = this.config.mode === 's3' ? 'S3-compatible LanceDB' : 'local LanceDB';
205
+ throw new Error(`Failed to connect to ${label}: ${err.message}`);
180
206
  }
181
207
  }
182
208
 
183
209
  // ── Cloud mode ───────────────────────────────────────────────────────────
184
- // connect() is lazy (never contacts the server), so we call tableNames()
185
210
  // to force a real authenticated HTTP round-trip.
186
211
  let conn: lancedb.Connection;
187
212
  try {
@@ -193,22 +218,21 @@ export class LanceDBAdapter implements VectorStoreAdapter {
193
218
  try {
194
219
  await conn.tableNames();
195
220
  } catch (err: any) {
196
- const msg: string = err.message ?? "";
221
+ const msg: string = err.message ?? '';
197
222
 
198
- // The SDK puts the HTTP status INSIDE the message string (not as a
199
223
  // property). Pattern: "Http error: ... 401 Unauthorized: Unauthorized"
200
224
  if (
201
- msg.includes("401") ||
202
- msg.includes("Unauthorized") ||
203
- msg.includes("403") ||
204
- msg.includes("Forbidden")
225
+ msg.includes('401') ||
226
+ msg.includes('Unauthorized') ||
227
+ msg.includes('403') ||
228
+ msg.includes('Forbidden')
205
229
  ) {
206
230
  throw new Error(
207
- "Invalid LanceDB Cloud API key. Please check your credentials in the LanceDB Cloud dashboard.",
231
+ 'Invalid LanceDB Cloud API key. Please check your credentials in the LanceDB Cloud dashboard.',
208
232
  );
209
233
  }
210
234
 
211
- if (msg.includes("404") || msg.includes("Not Found")) {
235
+ if (msg.includes('404') || msg.includes('Not Found')) {
212
236
  throw new Error(
213
237
  `LanceDB Cloud database not found. Please verify your URI (e.g. "db://my-database").`,
214
238
  );