tsense 0.1.0 → 0.2.0-next.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/tsense.js CHANGED
@@ -9,6 +9,23 @@ function chunkArray(arr, size) {
9
9
  return chunks;
10
10
  }
11
11
  const redaxiosInstance = redaxios.default ?? redaxios;
12
+ function escapeFilterValue(value) {
13
+ if (typeof value === "string") {
14
+ return `\`${value.replaceAll("`", "")}\``;
15
+ }
16
+ if (Array.isArray(value)) {
17
+ return value.map(escapeFilterValue);
18
+ }
19
+ return value;
20
+ }
21
+ const filterOperators = {
22
+ not: (k, v) => `${k}:!=${v}`,
23
+ gt: (k, v) => `${k}:>${v}`,
24
+ gte: (k, v) => `${k}:>=${v}`,
25
+ lt: (k, v) => `${k}:<${v}`,
26
+ lte: (k, v) => `${k}:<=${v}`,
27
+ notIn: (k, v) => `${k}:!=[${v.join(",")}]`,
28
+ };
12
29
  const arkToTsense = {
13
30
  string: "string",
14
31
  number: "float",
@@ -27,16 +44,13 @@ export class TSense {
27
44
  fieldTransformers = new Map();
28
45
  dataSyncConfig;
29
46
  infer = undefined;
30
- getFields() {
31
- return this.fields;
32
- }
33
47
  constructor(options) {
34
48
  this.options = options;
35
49
  this.axios = redaxiosInstance.create({
36
50
  baseURL: `${options.connection.protocol}://${options.connection.host}:${options.connection.port}`,
37
51
  headers: { "X-TYPESENSE-API-KEY": options.connection.apiKey },
38
52
  });
39
- this.extractFields(options.transformers ?? defaultTransformers);
53
+ this.fields = this.extractFields(options.transformers ?? defaultTransformers);
40
54
  this.dataSyncConfig = options.dataSync;
41
55
  }
42
56
  getBaseType(expression, domain) {
@@ -80,27 +94,38 @@ export class TSense {
80
94
  if (Array.isArray(value)) {
81
95
  return value.map((v) => transformer.serialize(v));
82
96
  }
83
- if (typeof value === "object" && value !== null) {
84
- const v = value;
85
- const isFilterObject = "min" in v || "max" in v || "not" in v;
86
- if (!isFilterObject) {
87
- return transformer.serialize(value);
97
+ if (typeof value === "object" &&
98
+ value !== null &&
99
+ Object.getPrototypeOf(value) === Object.prototype) {
100
+ const result = {};
101
+ for (const [opKey, opValue] of Object.entries(value)) {
102
+ if (opValue == null) {
103
+ result[opKey] = opValue;
104
+ continue;
105
+ }
106
+ if (Array.isArray(opValue)) {
107
+ result[opKey] = opValue.map((item) => transformer.serialize(item));
108
+ }
109
+ else {
110
+ result[opKey] = transformer.serialize(opValue);
111
+ }
88
112
  }
89
- const result = { ...v };
90
- if ("min" in v && v.min != null)
91
- result.min = transformer.serialize(v.min);
92
- if ("max" in v && v.max != null)
93
- result.max = transformer.serialize(v.max);
94
- if ("not" in v && v.not != null)
95
- result.not = transformer.serialize(v.not);
96
113
  return result;
97
114
  }
98
115
  return transformer.serialize(value);
99
116
  }
100
117
  extractFields(transformers) {
101
118
  const internal = this.options.schema;
119
+ const fields = [];
102
120
  for (const prop of internal.structure.props) {
103
- const innerType = prop.value.branches?.[0] ?? prop.value;
121
+ const branches = prop.value.branches ?? [];
122
+ const enumValues = [];
123
+ for (const branch of branches) {
124
+ if (typeof branch.unit === "string") {
125
+ enumValues.push(branch.unit);
126
+ }
127
+ }
128
+ const innerType = branches[0] ?? prop.value;
104
129
  const meta = (innerType.meta ?? prop.value.meta);
105
130
  const expression = String(prop.value.expression);
106
131
  const domain = prop.value.domain;
@@ -108,26 +133,31 @@ export class TSense {
108
133
  const transformer = transformers.find((t) => t.match(expression, domain) || t.match(baseType, domain));
109
134
  if (transformer) {
110
135
  this.fieldTransformers.set(prop.key, transformer);
111
- this.fields.push({
136
+ fields.push({
112
137
  name: prop.key,
113
138
  type: transformer.storageType,
139
+ sourceExpression: expression,
114
140
  optional: prop.kind === "optional",
115
141
  facet: meta?.facet,
116
142
  sort: meta?.sort,
117
143
  index: meta?.index,
144
+ enumValues,
118
145
  });
119
146
  continue;
120
147
  }
121
148
  const type = meta?.type ?? this.inferType(baseType);
122
- this.fields.push({
149
+ fields.push({
123
150
  name: prop.key,
124
151
  type,
152
+ sourceExpression: expression,
125
153
  optional: prop.kind === "optional",
126
154
  facet: meta?.facet,
127
155
  sort: meta?.sort,
128
156
  index: meta?.index,
157
+ enumValues,
129
158
  });
130
159
  }
160
+ return fields;
131
161
  }
132
162
  async ensureSynced(force) {
133
163
  if (!force && (this.synced || !this.options.autoSyncSchema))
@@ -139,24 +169,30 @@ export class TSense {
139
169
  await this.ensureSynced(true);
140
170
  }
141
171
  buildObjectFilter(key, value) {
142
- if (Array.isArray(value)) {
143
- return `(${key}:[${value.join(",")}])`;
144
- }
145
- const v = value;
146
- if ("not" in v) {
147
- return `${key}:!=${v.not}`;
148
- }
149
- const min = v.min ?? undefined;
150
- const max = v.max ?? undefined;
151
- if (min != null && max != null) {
152
- return `${key}:[${min}..${max}]`;
153
- }
154
- if (max != null) {
155
- return `${key}:<=${max}`;
172
+ if (value.gte != null && value.lte != null) {
173
+ const escaped = escapeFilterValue(value.gte);
174
+ const escapedLte = escapeFilterValue(value.lte);
175
+ const parts = [`${key}:[${escaped}..${escapedLte}]`];
176
+ for (const [op, opValue] of Object.entries(value)) {
177
+ if (op === "gte" || op === "lte" || opValue == null)
178
+ continue;
179
+ const builder = filterOperators[op];
180
+ if (builder) {
181
+ parts.push(builder(key, escapeFilterValue(opValue)));
182
+ }
183
+ }
184
+ return parts;
156
185
  }
157
- if (min != null) {
158
- return `${key}:>=${min}`;
186
+ const parts = [];
187
+ for (const [op, opValue] of Object.entries(value)) {
188
+ if (opValue == null)
189
+ continue;
190
+ const builder = filterOperators[op];
191
+ if (builder) {
192
+ parts.push(builder(key, escapeFilterValue(opValue)));
193
+ }
159
194
  }
195
+ return parts;
160
196
  }
161
197
  buildFilter(filter) {
162
198
  const result = [];
@@ -165,33 +201,40 @@ export class TSense {
165
201
  if (rawValue == null)
166
202
  continue;
167
203
  if (key === "OR") {
168
- const orFilter = [];
204
+ const orParts = [];
169
205
  for (const condition of rawValue) {
170
206
  const inner = this.buildFilter(condition);
171
- orFilter.push(`(${inner.join("||")})`);
207
+ orParts.push(`(${inner.join("&&")})`);
172
208
  }
173
- result.push(`(${orFilter.join("||")})`);
209
+ result.push(`(${orParts.join("||")})`);
174
210
  continue;
175
211
  }
176
212
  const value = this.serializeFilterValue(key, rawValue);
177
- switch (typeof value) {
178
- case "string":
179
- case "number":
180
- case "boolean":
181
- result.push(`${key}:=${value}`);
182
- break;
183
- case "object": {
184
- const built = this.buildObjectFilter(key, value);
185
- if (built)
186
- result.push(built);
187
- break;
188
- }
189
- default:
190
- break;
213
+ const escaped = escapeFilterValue(value);
214
+ if (typeof escaped === "string" ||
215
+ typeof escaped === "number" ||
216
+ typeof escaped === "boolean") {
217
+ result.push(`${key}:=${escaped}`);
218
+ continue;
219
+ }
220
+ if (Array.isArray(escaped)) {
221
+ result.push(`${key}:[${escaped.join(",")}]`);
222
+ continue;
223
+ }
224
+ if (typeof value === "object" && value !== null) {
225
+ result.push(...this.buildObjectFilter(key, value));
191
226
  }
192
227
  }
193
228
  return result;
194
229
  }
230
+ validateFields(fields) {
231
+ const valid = new Set(this.fields.map((f) => f.name));
232
+ for (const field of fields) {
233
+ if (field !== "score" && !valid.has(field)) {
234
+ throw new Error(`INVALID_FIELD: ${field}`);
235
+ }
236
+ }
237
+ }
195
238
  buildSort(options) {
196
239
  if (!options.sortBy)
197
240
  return;
@@ -289,9 +332,22 @@ export class TSense {
289
332
  }
290
333
  async search(options) {
291
334
  await this.ensureSynced();
335
+ const queryByFields = options.queryBy ?? [
336
+ this.options.defaultSearchField,
337
+ ];
338
+ this.validateFields(queryByFields);
339
+ if (options.sortBy) {
340
+ this.validateFields(options.sortBy
341
+ .map((s) => s.split(":")[0])
342
+ .filter((f) => f !== "undefined"));
343
+ }
344
+ if (options.facetBy) {
345
+ this.validateFields(options.facetBy);
346
+ }
347
+ const queryBy = queryByFields.join(",");
292
348
  const params = {
293
- q: options.query ?? "",
294
- query_by: (options.queryBy ?? [this.options.defaultSearchField]).join(","),
349
+ q: options.query ?? "*",
350
+ query_by: queryBy,
295
351
  };
296
352
  const sortBy = this.buildSort(options);
297
353
  if (sortBy)
@@ -313,10 +369,8 @@ export class TSense {
313
369
  params.exclude_fields = options.omit.join(",");
314
370
  }
315
371
  const highlight = options.highlight;
316
- const highlightEnabled = !!highlight;
317
- let highlightOpts;
318
- if (typeof highlight === "object") {
319
- highlightOpts = highlight;
372
+ const highlightOpts = typeof highlight === "object" ? highlight : undefined;
373
+ if (highlightOpts) {
320
374
  if (highlightOpts.fields) {
321
375
  params.highlight_fields = highlightOpts.fields.join(",");
322
376
  }
@@ -335,7 +389,7 @@ export class TSense {
335
389
  const data = [];
336
390
  const scores = [];
337
391
  for (const hit of res.hits ?? []) {
338
- if (highlightEnabled) {
392
+ if (highlight) {
339
393
  const fieldsToHighlight = highlightOpts?.fields;
340
394
  for (const [key, value] of Object.entries(hit.highlight ?? {})) {
341
395
  if (!value?.snippet)
@@ -365,35 +419,22 @@ export class TSense {
365
419
  };
366
420
  }
367
421
  async searchList(options) {
368
- await this.ensureSynced();
369
- const limit = Math.min(options.limit ?? 20, 100);
370
- const field = options.sort.field;
371
422
  const page = options.cursor ? Number(options.cursor) : 1;
372
- const params = {
373
- q: options.query ?? "",
374
- query_by: (options.queryBy ?? [this.options.defaultSearchField]).join(","),
375
- per_page: limit,
423
+ const limit = Math.min(options.limit ?? 20, 100);
424
+ const result = await this.search({
425
+ query: options.query,
426
+ queryBy: options.queryBy,
427
+ filter: options.filter,
428
+ sortBy: [options.sortBy],
376
429
  page,
377
- sort_by: `${field}:${options.sort.direction}`,
378
- };
379
- const filterParts = this.buildFilter(options.filter);
380
- const filterBy = filterParts.join("&&");
381
- if (filterBy)
382
- params.filter_by = filterBy;
383
- const { data: res } = await this.axios({
384
- method: "GET",
385
- url: `/collections/${this.options.name}/documents/search`,
386
- params,
430
+ limit,
387
431
  });
388
- const hits = res.hits ?? [];
389
- const data = [];
390
- for (const hit of hits) {
391
- const doc = this.deserializeDoc(hit.document);
392
- data.push(doc);
393
- }
394
- const hasMore = page * limit < res.found;
395
- const nextCursor = hasMore ? String(page + 1) : null;
396
- return { data, nextCursor, total: res.found };
432
+ const hasMore = page * limit < result.count;
433
+ return {
434
+ data: result.data,
435
+ nextCursor: hasMore ? String(page + 1) : null,
436
+ total: result.count,
437
+ };
397
438
  }
398
439
  async count(filter) {
399
440
  await this.ensureSynced();
@@ -405,15 +446,16 @@ export class TSense {
405
446
  });
406
447
  return data.num_documents;
407
448
  }
449
+ const params = {
450
+ q: "*",
451
+ query_by: this.options.defaultSearchField,
452
+ per_page: 0,
453
+ filter_by: filterBy,
454
+ };
408
455
  const { data } = await this.axios({
409
456
  method: "GET",
410
457
  url: `/collections/${this.options.name}/documents/search`,
411
- params: {
412
- q: "*",
413
- query_by: this.options.defaultSearchField,
414
- per_page: 0,
415
- filter_by: filterBy,
416
- },
458
+ params,
417
459
  });
418
460
  return data.found;
419
461
  }
@@ -427,7 +469,9 @@ export class TSense {
427
469
  this.options.schema.assert(item);
428
470
  }
429
471
  }
430
- const payload = items.map((item) => JSON.stringify(this.serializeDoc(item))).join("\n");
472
+ const payload = items
473
+ .map((item) => JSON.stringify(this.serializeDoc(item)))
474
+ .join("\n");
431
475
  const params = { action: "upsert" };
432
476
  if (this.options.batchSize) {
433
477
  params.batch_size = this.options.batchSize;
@@ -492,4 +536,19 @@ export class TSense {
492
536
  .filter((line) => line.length)
493
537
  .map((line) => JSON.parse(line).id);
494
538
  }
539
+ scoped(baseFilter) {
540
+ return {
541
+ search: (options) => this.search({
542
+ ...options,
543
+ filter: { ...options.filter, ...baseFilter },
544
+ }),
545
+ searchList: (options) => this.searchList({
546
+ ...options,
547
+ filter: { ...options.filter, ...baseFilter },
548
+ }),
549
+ count: (filter) => this.count({ ...filter, ...baseFilter }),
550
+ deleteMany: (filter) => this.deleteMany({ ...filter, ...baseFilter }),
551
+ updateMany: (filter, data) => this.updateMany({ ...filter, ...baseFilter }, data),
552
+ };
553
+ }
495
554
  }
package/dist/types.d.ts CHANGED
@@ -4,10 +4,12 @@ type BaseIfArray<T> = T extends (infer Q)[] ? Q : T;
4
4
  export type FieldSchema = {
5
5
  name: string;
6
6
  type: string;
7
+ sourceExpression?: string;
7
8
  facet?: boolean;
8
9
  sort?: boolean;
9
10
  index?: boolean;
10
11
  optional?: boolean;
12
+ enumValues?: string[];
11
13
  };
12
14
  export type SearchHit<T> = {
13
15
  document: T;
@@ -46,13 +48,29 @@ export type TsenseOptions<T extends Type> = {
46
48
  transformers?: FieldTransformer[];
47
49
  dataSync?: SyncConfig<T["infer"]>;
48
50
  };
51
+ export type StringFilter = {
52
+ not?: string;
53
+ notIn?: string[];
54
+ };
55
+ export type NumberFilter = {
56
+ not?: number;
57
+ notIn?: number[];
58
+ gt?: number;
59
+ gte?: number;
60
+ lt?: number;
61
+ lte?: number;
62
+ };
63
+ type DateFilter = {
64
+ not?: Date;
65
+ notIn?: Date[];
66
+ gt?: Date;
67
+ gte?: Date;
68
+ lt?: Date;
69
+ lte?: Date;
70
+ };
71
+ type FilterValueFor<T> = [T] extends [boolean] ? boolean : [T] extends [Date] ? Date | Date[] | DateFilter : [T] extends [number] ? number | number[] | NumberFilter : [T] extends [string] ? T | T[] | StringFilter : never;
49
72
  type SingleFilter<T> = Partial<{
50
- [K in keyof T]: BaseIfArray<T[K]> | NonNullable<BaseIfArray<T[K]>>[] | {
51
- not?: BaseIfArray<T[K]>;
52
- } | (NonNullable<T[K]> extends number | Date ? NonNullable<T[K]> extends infer Type ? {
53
- min?: Type;
54
- max?: Type;
55
- } : never : never);
73
+ [K in keyof T]: FilterValueFor<NonNullable<BaseIfArray<T[K]>>>;
56
74
  }>;
57
75
  export type FilterFor<T> = SingleFilter<T> & {
58
76
  OR?: FilterFor<T>[];
@@ -108,15 +126,17 @@ export type UpsertResult = {
108
126
  error?: string;
109
127
  document?: unknown;
110
128
  };
111
- type SearchListSort<T> = {
112
- field: keyof T;
113
- direction: "asc" | "desc";
129
+ export type SearchInput<T> = {
130
+ query?: string;
131
+ filter?: FilterFor<T>;
132
+ page?: number;
133
+ limit?: number;
114
134
  };
115
135
  export type SearchListOptions<T> = {
116
136
  query?: string;
117
137
  queryBy?: (keyof T)[];
118
138
  filter?: FilterFor<T>;
119
- sort: SearchListSort<T>;
139
+ sortBy: `${Extract<keyof T, string>}:${"asc" | "desc"}`;
120
140
  limit?: number;
121
141
  cursor?: string;
122
142
  };
@@ -125,6 +145,13 @@ export type SearchListResult<T> = {
125
145
  nextCursor: string | null;
126
146
  total: number;
127
147
  };
148
+ export type ScopedCollection<T> = {
149
+ search: <const O extends SearchOptions<T> = SearchOptionsPlain<T>>(options: O) => Promise<SearchResult<ProjectSearch<T, O>>>;
150
+ searchList: (options: SearchListOptions<T>) => Promise<SearchListResult<T>>;
151
+ count: (filter?: FilterFor<T>) => Promise<number>;
152
+ deleteMany: (filter: FilterFor<T>) => Promise<DeleteResult>;
153
+ updateMany: (filter: FilterFor<T>, data: Partial<T>) => Promise<UpdateResult>;
154
+ };
128
155
  export type SyncConfig<T> = {
129
156
  getAllIds: () => Promise<string[]>;
130
157
  getItems: (ids: string[]) => Promise<T[]>;
package/package.json CHANGED
@@ -1,51 +1,70 @@
1
1
  {
2
- "name": "tsense",
3
- "version": "0.1.0",
4
- "private": false,
5
- "description": "Opinionated, fully typed typesense client",
6
- "keywords": [
7
- "typesense"
8
- ],
9
- "homepage": "https://github.com/lobomfz/tsense",
10
- "license": "MIT",
11
- "files": [
12
- "dist"
13
- ],
14
- "type": "module",
15
- "sideEffects": false,
16
- "main": "dist/index.js",
17
- "types": "dist/index.d.ts",
18
- "exports": {
19
- ".": {
20
- "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
22
- }
23
- },
24
- "publishConfig": {
25
- "access": "public"
26
- },
27
- "scripts": {
28
- "build": "tsc",
29
- "ci": "bun run build && bun run format && bun run check-exports",
30
- "test": "bun test",
31
- "format": "oxfmt check --write src tests",
32
- "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm",
33
- "local-release": "changeset version && changeset publish",
34
- "prepublishOnly": "bun run ci"
35
- },
36
- "dependencies": {
37
- "redaxios": "^0.5.1"
38
- },
39
- "devDependencies": {
40
- "@arethetypeswrong/cli": "^0.18.2",
41
- "@changesets/cli": "^2.30.0",
42
- "@types/bun": "latest",
43
- "@typescript/native-preview": "^7.0.0-dev.20260324.1",
44
- "arktype": "^2.2.0",
45
- "oxfmt": "^0.42.0",
46
- "oxlint": "^1.57.0"
47
- },
48
- "peerDependencies": {
49
- "arktype": "^2.1.29"
50
- }
51
- }
2
+ "name": "tsense",
3
+ "version": "0.2.0-next.0",
4
+ "private": false,
5
+ "description": "Opinionated, fully typed typesense client",
6
+ "keywords": [
7
+ "typesense"
8
+ ],
9
+ "homepage": "https://github.com/lobomfz/tsense",
10
+ "license": "MIT",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./filters": {
24
+ "types": "./dist/filters/index.d.ts",
25
+ "import": "./dist/filters/index.js"
26
+ },
27
+ "./react": {
28
+ "types": "./dist/react/index.d.ts",
29
+ "import": "./dist/react/index.js"
30
+ }
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsgo",
37
+ "check": "bun run scripts/check.ts",
38
+ "ci": "bun run build && bun run format && bun run check-exports",
39
+ "test": "bun test",
40
+ "format": "oxfmt check --write src tests",
41
+ "check-exports": "attw --pack . --ignore-rules cjs-resolves-to-esm no-resolution",
42
+ "local-release": "changeset version && changeset publish",
43
+ "prepublishOnly": "bun run ci"
44
+ },
45
+ "dependencies": {
46
+ "redaxios": "^0.5.1"
47
+ },
48
+ "devDependencies": {
49
+ "@arethetypeswrong/cli": "^0.18.2",
50
+ "@changesets/cli": "^2.30.0",
51
+ "@types/bun": "latest",
52
+ "@types/node": "^25.5.0",
53
+ "@types/react": "^19.2.14",
54
+ "@typescript/native-preview": "^7.0.0-dev.20260324.1",
55
+ "arktype": "^2.2.0",
56
+ "jscpd": "^4.0.8",
57
+ "oxfmt": "^0.42.0",
58
+ "oxlint": "^1.57.0",
59
+ "react": "^19.2.4"
60
+ },
61
+ "peerDependencies": {
62
+ "arktype": "^2.1.29",
63
+ "react": ">=18"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "react": {
67
+ "optional": true
68
+ }
69
+ }
70
+ }