tsense 0.1.0 → 0.2.0-next.2

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/README.md CHANGED
@@ -1,168 +1,163 @@
1
1
  # TSense
2
2
 
3
- Opinionated, fully-typed Typesense client powered by [Arktype](https://arktype.io/)
3
+ Fully-typed Typesense client powered by [Arktype](https://arktype.io/).
4
4
 
5
- ## Installation
5
+ Define your schema once. Get type-safe search, filtering, and automatic filter UIs — all from the same source of truth.
6
6
 
7
7
  ```bash
8
8
  bun add tsense arktype
9
9
  ```
10
10
 
11
- ## Example
11
+ ## Define a collection
12
12
 
13
13
  ```typescript
14
14
  import { type } from "arktype";
15
15
  import { TSense } from "tsense";
16
16
 
17
- const UsersCollection = new TSense({
17
+ const Users = new TSense({
18
18
  name: "users",
19
19
  schema: type({
20
20
  "id?": "string",
21
- email: "string",
21
+ name: type("string").configure({ sort: true, index: true }),
22
22
  age: type("number.integer").configure({ type: "int32", sort: true }),
23
- "company?": type.enumerated("netflix", "google").configure({ facet: true }),
24
- "phone?": "string",
25
- name: type("string").configure({ sort: true }),
26
- "work_history?": type({
27
- company: "string",
28
- date: "string",
29
- })
30
- .array()
31
- .configure({ type: "object[]", index: false }),
23
+ company: type
24
+ .enumerated("netflix", "google", "apple")
25
+ .configure({ facet: true }),
26
+ active: type("boolean").configure({ facet: true }),
27
+ "joined_at?": "Date",
32
28
  }),
33
29
  connection: {
34
30
  host: "127.0.0.1",
35
31
  port: 8108,
36
32
  protocol: "http",
37
- apiKey: "123",
33
+ apiKey: "xyz123",
38
34
  },
39
35
  defaultSearchField: "name",
40
- validateOnUpsert: true,
41
36
  });
37
+ ```
42
38
 
43
- type User = typeof UsersCollection.infer;
44
-
45
- await UsersCollection.create();
39
+ ## Search with typed filters
46
40
 
47
- await UsersCollection.upsert([
48
- { id: "1", email: "john@example.com", age: 30, name: "John Doe", company: "netflix" },
49
- { id: "2", email: "jane@example.com", age: 25, name: "Jane Smith", company: "google" },
50
- ]);
41
+ Operators are restricted per field type — the compiler catches invalid combinations.
51
42
 
52
- const results = await UsersCollection.search({
53
- query: "john",
54
- queryBy: ["name", "email"],
55
- sortBy: ["age:desc", "name:asc"],
43
+ ```typescript
44
+ await Users.search({
56
45
  filter: {
57
- age: { min: 20 },
58
- OR: [{ company: "google" }, { company: "netflix" }],
46
+ age: { gte: 18, lte: 40 },
47
+ company: ["netflix", "google"],
48
+ name: { not: "admin" },
59
49
  },
60
50
  });
51
+ ```
61
52
 
62
- const faceted = await UsersCollection.search({
63
- query: "john",
64
- facetBy: ["company"],
65
- });
53
+ ## Paginated lists
66
54
 
67
- const highlighted = await UsersCollection.search({
68
- query: "john",
69
- highlight: true,
55
+ ```typescript
56
+ const page1 = await Users.searchList({
57
+ sortBy: "age:asc",
58
+ filter: { active: true },
59
+ limit: 20,
70
60
  });
71
61
 
72
- await UsersCollection.drop();
62
+ const page2 = await Users.searchList({
63
+ sortBy: "age:asc",
64
+ filter: { active: true },
65
+ limit: 20,
66
+ cursor: page1.nextCursor!,
67
+ });
73
68
  ```
74
69
 
75
- ## API Reference
70
+ ## Count
76
71
 
77
- ### Schema Configuration
72
+ ```typescript
73
+ const total = await Users.count();
74
+ const active = await Users.count({ active: true });
75
+ ```
78
76
 
79
- Use `.configure()` to set Typesense field options:
77
+ ## Multi-tenancy with scoped
78
+
79
+ `scoped()` returns a narrowed instance where a base filter is merged into every operation. The caller cannot override it.
80
80
 
81
81
  ```typescript
82
- type("string").configure({
83
- type: "string",
84
- facet: true,
85
- sort: true,
86
- index: true,
87
- });
88
- ```
82
+ const myUsers = Users.scoped({ owner_id: currentUser.id });
89
83
 
90
- ### Collection Methods
84
+ await myUsers.search({ filter: { active: true } });
85
+ // filter_by = "active:=true && owner_id:=`user-1`"
91
86
 
92
- | Method/Property | Description |
93
- | -------------------------- | ----------------------------------------- |
94
- | `create()` | Creates the collection in Typesense |
95
- | `drop()` | Deletes the collection |
96
- | `get(id)` | Retrieves a document by ID |
97
- | `delete(id)` | Deletes a document by ID |
98
- | `deleteMany(filter)` | Deletes documents matching filter |
99
- | `update(id, data)` | Updates a document by ID |
100
- | `updateMany(filter, data)` | Updates documents matching filter |
101
- | `upsert(docs)` | Inserts or updates documents |
102
- | `search(options)` | Searches the collection |
103
- | `syncSchema()` | Syncs schema (creates/patches collection) |
104
- | `syncData(options)` | Syncs data from external source |
105
- | `fields` | Array of generated field schemas |
87
+ await myUsers.count({ active: true });
88
+ await myUsers.deleteMany({ active: false });
89
+ ```
106
90
 
107
- ### Schema Sync
91
+ ## Build filter UIs from the schema
108
92
 
109
- Automatically sync schema before the first operation:
93
+ Declare which fields are filterable. tsense introspects the arktype schema, detects enums, and produces a descriptor that travels to the frontend as JSON.
110
94
 
111
95
  ```typescript
112
- const Collection = new TSense({
113
- // ...
114
- autoSyncSchema: true,
96
+ import { createFilterBuilder } from "tsense/filters";
97
+
98
+ const filters = createFilterBuilder(Users, {
99
+ name: { label: "Name" },
100
+ age: {
101
+ label: "Age",
102
+ presets: { "Over 18": { age: { gte: 18 } } },
103
+ },
104
+ company: {
105
+ label: "Company",
106
+ labels: { netflix: "Netflix", google: "Google", apple: "Apple" },
107
+ },
108
+ active: { label: "Active" },
109
+ joined_at: { label: "Joined At" },
115
110
  });
111
+
112
+ const descriptor = filters.describe();
116
113
  ```
117
114
 
118
- Or manually:
115
+ ## Render it
119
116
 
120
- ```typescript
121
- await Collection.syncSchema();
117
+ Drop the descriptor into the React component. The user picks columns, conditions, and values. You get a typed `FilterFor<User>` back.
118
+
119
+ ```tsx
120
+ import { FilterBuilder } from "tsense/react";
121
+
122
+ <FilterBuilder descriptor={descriptor} onChange={(filter) => search(filter)} />;
122
123
  ```
123
124
 
124
- ### Data Sync
125
+ Every slot is replaceable via render props — or use the headless `useFilterBuilder` hook for full control.
126
+
127
+ ## Wire it end-to-end
125
128
 
126
- Sync documents from an external source (database, API, etc.):
129
+ Backend validates input with the filter builder schema, scopes results by user, and exposes the descriptor. Frontend renders the builder and fires queries.
127
130
 
128
131
  ```typescript
129
- const Collection = new TSense({
130
- // ...
131
- dataSync: {
132
- getAllIds: async () => {
133
- return db
134
- .selectFrom("users")
135
- .select("id")
136
- .execute()
137
- .then((rows) => rows.map((r) => r.id));
138
- },
139
- getItems: async (ids) => {
140
- return db.selectFrom("users").where("id", "in", ids).execute();
141
- },
142
- chunkSize: 100, // optional, default 500
143
- },
132
+ // backend
133
+ const filters = createFilterBuilder(Users, config);
134
+
135
+ const authed = base.use(({ context, next }) => {
136
+ if (!context.user) throw new ORPCError("UNAUTHORIZED");
137
+ return next({ context: { user: context.user } });
144
138
  });
145
139
 
146
- // Full sync
147
- await Collection.syncData();
140
+ const router = base.router({
141
+ search: authed
142
+ .input(filters.schema())
143
+ .handler(({ input, context }) =>
144
+ Users.scoped({ owner_id: context.user.id }).search(input),
145
+ ),
146
+ });
147
+ ```
148
148
 
149
- // Partial sync (specific IDs)
150
- await Collection.syncData({ ids: ["id1", "id2"] });
149
+ ```tsx
150
+ // frontend
151
+ import { describe } from "./api/describe" with { type: "macro" };
151
152
 
152
- // Full sync + remove orphan documents
153
- await Collection.syncData({ purge: true });
153
+ const descriptor = describe();
154
+ const [filter, setFilter] = useState<typeof descriptor.infer>({});
154
155
 
155
- // Override chunk size
156
- await Collection.syncData({ chunkSize: 50 });
156
+ <FilterBuilder descriptor={descriptor} onChange={setFilter} />;
157
+
158
+ const { data: results } = useQuery(api.search, { filter });
157
159
  ```
158
160
 
159
- ### Filter Syntax
161
+ The descriptor is inlined at build time via [Bun macros](https://bun.sh/docs/bundler/macros) — no RPC call needed.
160
162
 
161
- ```typescript
162
- filter: { name: "John" } // Exact match
163
- filter: { age: 30 } // Numeric match
164
- filter: { age: [25, 30, 35] } // IN
165
- filter: { age: { min: 20, max: 40 } } // Range
166
- filter: { name: { not: "John" } } // Not equal
167
- filter: { OR: [{ age: 25 }, { age: 30 }] } // OR conditions
168
- ```
163
+ A working example with docker-compose, seed data, and a full UI lives in [`examples/`](./examples).
@@ -0,0 +1,23 @@
1
+ import type { FilterDescriptor } from "./index.js";
2
+ export type FilterValue = string | string[] | number | boolean | Date | [FilterValue | undefined, FilterValue | undefined] | undefined;
3
+ export type FilterRow = {
4
+ id: number;
5
+ field?: string;
6
+ condition?: string;
7
+ value?: FilterValue;
8
+ };
9
+ export type FilterState = {
10
+ rows: FilterRow[];
11
+ };
12
+ export declare function createInitialState(): FilterState;
13
+ export declare function addRow(state: FilterState): FilterState;
14
+ export declare function addRowWithField(state: FilterState, field: string): FilterState;
15
+ export declare function removeRow(state: FilterState, index: number): FilterState;
16
+ export declare function setRowField(state: FilterState, index: number, field: string): FilterState;
17
+ export declare function setRowCondition(state: FilterState, index: number, condition: string): FilterState;
18
+ export declare function setRowValue(state: FilterState, index: number, value: FilterValue): FilterState;
19
+ export declare function clearState(): FilterState;
20
+ export declare function applyPreset<T>(state: FilterState, descriptor: FilterDescriptor<T>, field: string, name: string): FilterState;
21
+ export declare function conditionsFor<T>(descriptor: FilterDescriptor<T>, field: string): FilterDescriptor<T>["columns"][number]["conditions"];
22
+ export declare function columnFor<T>(descriptor: FilterDescriptor<T>, field: string): FilterDescriptor<T>["columns"][number];
23
+ export declare function buildResult(state: FilterState): Record<string, unknown>;
@@ -0,0 +1,152 @@
1
+ const conditionToOperator = {
2
+ equals: null,
3
+ not_equals: "not",
4
+ gt: "gt",
5
+ gte: "gte",
6
+ lt: "lt",
7
+ lte: "lte",
8
+ between: null,
9
+ is_in: null,
10
+ is_not_in: "notIn",
11
+ };
12
+ let nextRowId = 0;
13
+ export function createInitialState() {
14
+ return { rows: [] };
15
+ }
16
+ export function addRow(state) {
17
+ return { rows: [...state.rows, { id: ++nextRowId }] };
18
+ }
19
+ export function addRowWithField(state, field) {
20
+ return { rows: [...state.rows, { id: ++nextRowId, field }] };
21
+ }
22
+ export function removeRow(state, index) {
23
+ return { rows: state.rows.filter((_, i) => i !== index) };
24
+ }
25
+ export function setRowField(state, index, field) {
26
+ return {
27
+ rows: state.rows.map((row, i) => i === index ? { id: row.id, field } : row),
28
+ };
29
+ }
30
+ export function setRowCondition(state, index, condition) {
31
+ return {
32
+ rows: state.rows.map((row, i) => i === index ? { ...row, condition, value: undefined } : row),
33
+ };
34
+ }
35
+ export function setRowValue(state, index, value) {
36
+ return {
37
+ rows: state.rows.map((row, i) => (i === index ? { ...row, value } : row)),
38
+ };
39
+ }
40
+ export function clearState() {
41
+ return { rows: [] };
42
+ }
43
+ function filterValueToRow(field, filterValue) {
44
+ if (filterValue == null) {
45
+ return null;
46
+ }
47
+ if (Array.isArray(filterValue)) {
48
+ return {
49
+ id: ++nextRowId,
50
+ field,
51
+ condition: "is_in",
52
+ value: filterValue,
53
+ };
54
+ }
55
+ if (typeof filterValue !== "object" || filterValue instanceof Date) {
56
+ return {
57
+ id: ++nextRowId,
58
+ field,
59
+ condition: "equals",
60
+ value: filterValue,
61
+ };
62
+ }
63
+ const ops = filterValue;
64
+ const keys = Object.keys(ops);
65
+ if (keys.includes("gte") && keys.includes("lte")) {
66
+ return {
67
+ id: ++nextRowId,
68
+ field,
69
+ condition: "between",
70
+ value: [ops.gte, ops.lte],
71
+ };
72
+ }
73
+ if (keys[0]) {
74
+ return { id: ++nextRowId, field, condition: keys[0], value: ops[keys[0]] };
75
+ }
76
+ return null;
77
+ }
78
+ export function applyPreset(state, descriptor, field, name) {
79
+ const column = descriptor.columns.find((c) => c.key === field);
80
+ const preset = column?.presets?.find((p) => p.name === name);
81
+ if (!preset) {
82
+ return state;
83
+ }
84
+ const rows = [];
85
+ for (const [key, value] of Object.entries(preset.filter)) {
86
+ const row = filterValueToRow(key, value);
87
+ if (row) {
88
+ rows.push(row);
89
+ }
90
+ }
91
+ if (!rows.length) {
92
+ return state;
93
+ }
94
+ return { rows };
95
+ }
96
+ export function conditionsFor(descriptor, field) {
97
+ const column = descriptor.columns.find((c) => c.key === field);
98
+ return column?.conditions ?? [];
99
+ }
100
+ export function columnFor(descriptor, field) {
101
+ const column = descriptor.columns.find((c) => c.key === field);
102
+ if (!column) {
103
+ throw new Error(`Column not found: ${field}`);
104
+ }
105
+ return column;
106
+ }
107
+ function isRowComplete(row) {
108
+ if (!row.field || !row.condition || row.value == null)
109
+ return false;
110
+ if (row.condition === "between") {
111
+ return (Array.isArray(row.value) && row.value[0] != null && row.value[1] != null);
112
+ }
113
+ if (row.condition === "is_in" || row.condition === "is_not_in") {
114
+ return Array.isArray(row.value) && row.value.length > 0;
115
+ }
116
+ return true;
117
+ }
118
+ export function buildResult(state) {
119
+ const result = {};
120
+ for (const row of state.rows) {
121
+ if (!isRowComplete(row))
122
+ continue;
123
+ if (row.condition === "equals" || row.condition === "is_in") {
124
+ result[row.field] = row.value;
125
+ continue;
126
+ }
127
+ if (row.condition === "between") {
128
+ const [min, max] = row.value;
129
+ const existing = typeof result[row.field] === "object" && result[row.field] !== null
130
+ ? result[row.field]
131
+ : {};
132
+ result[row.field] = { ...existing, gte: min, lte: max };
133
+ continue;
134
+ }
135
+ const operator = conditionToOperator[row.condition];
136
+ if (!operator)
137
+ continue;
138
+ const existing = result[row.field];
139
+ if (typeof existing === "object" &&
140
+ existing !== null &&
141
+ !Array.isArray(existing)) {
142
+ result[row.field] = {
143
+ ...existing,
144
+ [operator]: row.value,
145
+ };
146
+ }
147
+ else {
148
+ result[row.field] = { [operator]: row.value };
149
+ }
150
+ }
151
+ return result;
152
+ }
@@ -0,0 +1,40 @@
1
+ export { deserializeFilter, serializeFilter } from "./url.js";
2
+ import type { Type } from "arktype";
3
+ import type { TSense } from "../tsense.js";
4
+ import type { FilterFor, SearchInput } from "../types.js";
5
+ export type FilterDescriptor<T = Record<string, unknown>> = {
6
+ infer: FilterFor<T>;
7
+ columns: {
8
+ key: keyof T & string;
9
+ label: string;
10
+ type: "string" | "number" | "boolean" | "date";
11
+ conditions: {
12
+ key: string;
13
+ label: string;
14
+ }[];
15
+ values?: {
16
+ value: string;
17
+ label: string;
18
+ }[];
19
+ presets?: {
20
+ name: string;
21
+ filter: Record<string, unknown>;
22
+ }[];
23
+ }[];
24
+ };
25
+ type FilterBuilderFieldConfig<T> = {
26
+ label: string;
27
+ labels?: Record<string, string>;
28
+ presets?: Record<string, FilterFor<T> | (() => FilterFor<T>)>;
29
+ };
30
+ type FilterBuilderReturn<T> = {
31
+ describe(): FilterDescriptor<T>;
32
+ schema(): Type<SearchInput<T>>;
33
+ };
34
+ type ColumnType = FilterDescriptor["columns"][number]["type"];
35
+ type FilterBuilderOptions = {
36
+ conditionLabels?: Partial<Record<ColumnType | "enum", Partial<Record<string, string>>>>;
37
+ };
38
+ export declare function createFilterBuilder<T extends Type>(collection: TSense<T>, config: {
39
+ [K in keyof T["infer"]]?: FilterBuilderFieldConfig<T["infer"]>;
40
+ }, options?: FilterBuilderOptions): FilterBuilderReturn<T["infer"]>;
@@ -0,0 +1,140 @@
1
+ export { deserializeFilter, serializeFilter } from "./url.js";
2
+ import { type } from "arktype";
3
+ const tsenseTypeMap = {
4
+ string: "string",
5
+ "string*": "string",
6
+ "string[]": "string",
7
+ int32: "number",
8
+ int64: "number",
9
+ float: "number",
10
+ "int32[]": "number",
11
+ "int64[]": "number",
12
+ "float[]": "number",
13
+ bool: "boolean",
14
+ "bool[]": "boolean",
15
+ auto: "string",
16
+ image: "string",
17
+ };
18
+ const enumConditions = [
19
+ { key: "is_in", label: "is in" },
20
+ { key: "is_not_in", label: "is not in" },
21
+ ];
22
+ const conditionsByType = {
23
+ string: [
24
+ { key: "equals", label: "equals" },
25
+ { key: "not_equals", label: "not equals" },
26
+ ],
27
+ number: [
28
+ { key: "equals", label: "equals" },
29
+ { key: "not_equals", label: "not equals" },
30
+ { key: "gt", label: "greater than" },
31
+ { key: "gte", label: "greater than or equal" },
32
+ { key: "lt", label: "less than" },
33
+ { key: "lte", label: "less than or equal" },
34
+ { key: "between", label: "between" },
35
+ ],
36
+ boolean: [{ key: "equals", label: "equals" }],
37
+ date: [
38
+ { key: "equals", label: "equals" },
39
+ { key: "not_equals", label: "not equals" },
40
+ { key: "gt", label: "after" },
41
+ { key: "lt", label: "before" },
42
+ { key: "between", label: "between" },
43
+ ],
44
+ };
45
+ export function createFilterBuilder(collection, config, options) {
46
+ const fields = collection.fields;
47
+ return {
48
+ schema() {
49
+ const numberOps = type.raw({
50
+ "not?": "number",
51
+ "gt?": "number",
52
+ "gte?": "number",
53
+ "lt?": "number",
54
+ "lte?": "number",
55
+ "notIn?": "number[]",
56
+ });
57
+ const stringOps = type.raw({
58
+ "not?": "string",
59
+ "notIn?": "string[]",
60
+ });
61
+ const dateInput = "number | string | Date";
62
+ const dateOps = type.raw({
63
+ "not?": dateInput,
64
+ "gt?": dateInput,
65
+ "gte?": dateInput,
66
+ "lt?": dateInput,
67
+ "lte?": dateInput,
68
+ "notIn?": `(${dateInput})[]`,
69
+ });
70
+ const fieldSchemas = {
71
+ number: type.raw("number").or(type.raw("number[]")).or(numberOps),
72
+ string: type.raw("string").or(type.raw("string[]")).or(stringOps),
73
+ boolean: type.raw("boolean"),
74
+ date: type
75
+ .raw(dateInput)
76
+ .or(type.raw(`(${dateInput})[]`))
77
+ .or(dateOps),
78
+ };
79
+ const descriptor = this.describe();
80
+ const filterDef = {};
81
+ for (const column of descriptor.columns) {
82
+ filterDef[`${column.key}?`] = fieldSchemas[column.type];
83
+ }
84
+ return type
85
+ .raw({
86
+ "query?": "string",
87
+ "filter?": type.raw(filterDef),
88
+ "page?": "number",
89
+ "limit?": "number",
90
+ })
91
+ .as();
92
+ },
93
+ describe() {
94
+ const columns = [];
95
+ const withLabels = (conditions, typeKey) => {
96
+ const overrides = options?.conditionLabels?.[typeKey];
97
+ if (!overrides) {
98
+ return conditions;
99
+ }
100
+ return conditions.map((c) => ({
101
+ key: c.key,
102
+ label: overrides[c.key] ?? c.label,
103
+ }));
104
+ };
105
+ for (const field of fields) {
106
+ const fieldConfig = config[field.name];
107
+ if (!fieldConfig)
108
+ continue;
109
+ const columnType = field.sourceExpression === "Date"
110
+ ? "date"
111
+ : tsenseTypeMap[field.type];
112
+ if (!columnType)
113
+ continue;
114
+ const column = {
115
+ key: field.name,
116
+ label: fieldConfig.label,
117
+ type: columnType,
118
+ conditions: withLabels(conditionsByType[columnType], columnType),
119
+ };
120
+ if (field.enumValues?.length) {
121
+ column.values = field.enumValues.map((v) => ({
122
+ value: v,
123
+ label: fieldConfig.labels?.[v] ?? v,
124
+ }));
125
+ column.conditions = withLabels(enumConditions, "enum");
126
+ }
127
+ if (fieldConfig.presets) {
128
+ column.presets = Object.entries(fieldConfig.presets).map(([name, filterOrFn]) => ({
129
+ name,
130
+ filter: (typeof filterOrFn === "function"
131
+ ? filterOrFn()
132
+ : filterOrFn),
133
+ }));
134
+ }
135
+ columns.push(column);
136
+ }
137
+ return { infer: undefined, columns };
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,3 @@
1
+ import type { FilterDescriptor } from "./index.js";
2
+ export declare function serializeFilter(filter: Record<string, unknown>, descriptor: FilterDescriptor): URLSearchParams;
3
+ export declare function deserializeFilter(params: URLSearchParams, descriptor: FilterDescriptor): Record<string, unknown>;