@rebasepro/client 0.13.0 → 0.13.1-canary.g06dbe5b
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/index.d.ts +1 -0
- package/dist/index.es.js +452 -84
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +59 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +144 -0
- package/dist/sdk_query_builder.d.ts +46 -4
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +200 -57
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +116 -2
- package/src/offline-query.ts +123 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +180 -2
- package/src/offline.ts +169 -12
- package/src/query-contract.types.ts +206 -0
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +56 -4
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +34 -0
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +34 -9
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
/** The request never reached the server, so nothing was decided by it. */
|
|
14
14
|
export declare function isNetworkError(error: unknown): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Is the server still answering an earlier attempt of this same write?
|
|
17
|
+
*
|
|
18
|
+
* The only correct response is to ask again — which is exactly what the
|
|
19
|
+
* server's own message says, and exactly what this SDK used not to do.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isIdempotencyInProgressError(error: unknown): boolean;
|
|
15
22
|
/** Is this failure worth another attempt later? */
|
|
16
23
|
export declare function isRetryableError(error: unknown): boolean;
|
|
17
24
|
/**
|
|
@@ -24,6 +31,11 @@ export declare function isRetryableError(error: unknown): boolean;
|
|
|
24
31
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
25
32
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
26
33
|
* the server can only be rejecting it because the first attempt actually landed.
|
|
34
|
+
*
|
|
35
|
+
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
36
|
+
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
37
|
+
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
38
|
+
* concluded there was nothing left to do and deleted the write from the queue.
|
|
27
39
|
*/
|
|
28
40
|
export declare function isDuplicateKeyError(error: unknown): boolean;
|
|
29
41
|
export interface ConnectivityOptions {
|
package/dist/offline-query.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { FilterValues, FindResult, LogicalCondition, FilterCondition, OrderByTuple, WhereFilterOp } from "@rebasepro/types";
|
|
2
2
|
import { FindParams } from "./transport";
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The server's page size when the caller does not ask for one.
|
|
5
|
+
*
|
|
6
|
+
* Re-exported rather than redeclared. This was its own `= 20` — a third
|
|
7
|
+
* constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
|
|
8
|
+
* the 50 the REST layer actually applies — and a local copy of a number that
|
|
9
|
+
* belongs to another process is a number that goes stale silently.
|
|
10
|
+
*/
|
|
11
|
+
export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
|
|
5
12
|
/**
|
|
6
13
|
* Three-way compare with SQL's type coercion but not its collation. Returns
|
|
7
14
|
* `undefined` when the two values are not ordered relative to each other,
|
|
@@ -33,7 +40,15 @@ export declare function matchesParams(row: Record<string, unknown>, params?: Fin
|
|
|
33
40
|
* not shuffle rows between pages.
|
|
34
41
|
*/
|
|
35
42
|
export declare function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderByTuple): M[];
|
|
36
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
45
|
+
*
|
|
46
|
+
* It did not: this defaulted an absent limit to 20 while `/api/data` pages by
|
|
47
|
+
* 50, so the same `observe()` answered with 20 rows from the local database and
|
|
48
|
+
* 50 from the network — a list that changed length depending on which side
|
|
49
|
+
* answered, with `page` striding differently on each. Delegated now, so the
|
|
50
|
+
* sentence above is true by construction rather than by agreement.
|
|
51
|
+
*/
|
|
37
52
|
export declare function resolvePagination(params?: FindParams): {
|
|
38
53
|
limit: number;
|
|
39
54
|
offset: number;
|
|
@@ -45,7 +60,48 @@ export declare function resolvePagination(params?: FindParams): {
|
|
|
45
60
|
* `include` pulls in rows from other collections that this evaluator never
|
|
46
61
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
47
62
|
* best effort rather than an equivalent one.
|
|
63
|
+
*
|
|
64
|
+
* **Ordering comparisons are refused, and that is the interesting one.**
|
|
65
|
+
* `compareValues` falls back to an `Intl.Collator` for operands it cannot read
|
|
66
|
+
* as numbers or instants. PostgreSQL orders text by the *database's* collation,
|
|
67
|
+
* which is a property of the server this process has never been told: under the
|
|
68
|
+
* C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
|
|
69
|
+
* and the collator says true. So `["<", "Banana"]` selects a different set here
|
|
70
|
+
* than it does there — silently, and in whichever direction the deployment
|
|
71
|
+
* happens to have been created.
|
|
72
|
+
*
|
|
73
|
+
* The refusal covers *every* ordering comparison rather than only the ones with
|
|
74
|
+
* a string operand, because the operand type does not settle it: a numeric
|
|
75
|
+
* bound against a text column (`["<", 10]` on a `varchar`) also reaches the
|
|
76
|
+
* collator, and nothing in `params` says what the column holds. Conservative on
|
|
77
|
+
* purpose — the cost is that a query combining an ordering filter with
|
|
78
|
+
* *unsynced local writes* stops placing those writes optimistically, which is a
|
|
79
|
+
* degraded answer rather than a wrong one. Claiming exactness we do not have is
|
|
80
|
+
* the other way round.
|
|
81
|
+
*
|
|
82
|
+
* This says nothing about ordering *results*; that is a separate claim with a
|
|
83
|
+
* separate answer, because a sort changes which rows come first and not which
|
|
84
|
+
* rows match. See {@link isLocallySortable}.
|
|
48
85
|
*/
|
|
49
86
|
export declare function isExactlyEvaluable(params?: FindParams): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
89
|
+
*
|
|
90
|
+
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
91
|
+
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
92
|
+
* collator only when it cannot read both operands as numbers, and `toComparable`
|
|
93
|
+
* has already turned dates and relations into numbers and ids by then. If every
|
|
94
|
+
* value on the sort column normalises to a number, the collator is unreachable
|
|
95
|
+
* and the local order is the server's order.
|
|
96
|
+
*
|
|
97
|
+
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
98
|
+
* the two cannot be made to agree — and so is a column this page happens to see
|
|
99
|
+
* only as strings, which is the same thing from here.
|
|
100
|
+
*
|
|
101
|
+
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
102
|
+
* ascending, first descending) that matches Postgres and never reaches the
|
|
103
|
+
* comparator.
|
|
104
|
+
*/
|
|
105
|
+
export declare function isLocallySortable(rows: readonly Record<string, unknown>[], orderBy?: OrderByTuple): boolean;
|
|
50
106
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
51
107
|
export declare function runLocalQuery<M extends Record<string, unknown>>(rows: M[], params?: FindParams): FindResult<M>;
|
package/dist/offline-store.d.ts
CHANGED
|
@@ -48,9 +48,16 @@ export interface PendingMutation {
|
|
|
48
48
|
/** Unique, lexicographically sortable identity — also the queue key suffix. */
|
|
49
49
|
mutationId: string;
|
|
50
50
|
collection: string;
|
|
51
|
-
type: "create" | "createMany" | "update" | "delete";
|
|
51
|
+
type: "create" | "createMany" | "update" | "updateMany" | "delete" | "deleteMany";
|
|
52
52
|
/** Target row id for update/delete, and the (client-generated) id of an offline create. */
|
|
53
53
|
id?: string | number;
|
|
54
|
+
/** Target row ids for `deleteMany`. */
|
|
55
|
+
ids?: (string | number)[];
|
|
56
|
+
/** `{ id, data }` entries for `updateMany`. */
|
|
57
|
+
updates?: {
|
|
58
|
+
id: string | number;
|
|
59
|
+
data: Record<string, unknown>;
|
|
60
|
+
}[];
|
|
54
61
|
/**
|
|
55
62
|
* True when the SDK minted this create's id itself. Only such creates may
|
|
56
63
|
* cancel out against a later offline delete: a freshly generated UUID
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time assertions about the query surface.
|
|
3
|
+
*
|
|
4
|
+
* ## Read this before adding a `.test.ts` for a type
|
|
5
|
+
*
|
|
6
|
+
* These assertions are **not** in a test file, on purpose. In this repo a jest
|
|
7
|
+
* test cannot check a type at all:
|
|
8
|
+
*
|
|
9
|
+
* - `ts-jest` is configured transpile-only. Verified: a test containing
|
|
10
|
+
* `const n: number = "nope"` passes. `@ts-expect-error` in a `.test.ts` is
|
|
11
|
+
* therefore inert — it asserts nothing and never fails.
|
|
12
|
+
* - `tsconfig.typecheck.json` — the gate CI runs as `pnpm run typecheck` —
|
|
13
|
+
* covers every package's `src` directory but **excludes every `*.test.ts`**.
|
|
14
|
+
*
|
|
15
|
+
* So a type assertion written as a test is checked by nothing, twice over. This
|
|
16
|
+
* file is a plain module under `src`, which is exactly what the gate does read.
|
|
17
|
+
* It is imported by nothing and emits no runtime code.
|
|
18
|
+
*
|
|
19
|
+
* ## What went wrong that this exists to prevent
|
|
20
|
+
*
|
|
21
|
+
* `_score` was accepted by the runtime, documented in the SDK docs and skills,
|
|
22
|
+
* and rejected by `orderBy`'s type, which was `keyof M`. On a project with a
|
|
23
|
+
* generated SDK — where `M` is a concrete row type — the documented call was a
|
|
24
|
+
* compile error. Nothing in this repo noticed; a downstream application did.
|
|
25
|
+
*/
|
|
26
|
+
import type { FindParams, FindResult, SDKQueryBuilderInterface, WhereFilterOp } from "@rebasepro/types";
|
|
27
|
+
/**
|
|
28
|
+
* A row shaped the way a **generated** SDK shapes one: a type alias with a
|
|
29
|
+
* finite key set.
|
|
30
|
+
*
|
|
31
|
+
* This detail is the whole test. An `interface … extends Record<string,
|
|
32
|
+
* unknown>` also satisfies the constraint, but its index signature makes
|
|
33
|
+
* `keyof M` collapse to `string` — so every assertion below would pass no
|
|
34
|
+
* matter what `orderBy` accepted, typos included. That is how the first draft
|
|
35
|
+
* of this file was written, and every `@ts-expect-error` in it reported
|
|
36
|
+
* "unused directive": the fixture proved nothing.
|
|
37
|
+
*
|
|
38
|
+
* A generated row type has no index signature, which is exactly why a real
|
|
39
|
+
* project caught what this repo did not.
|
|
40
|
+
*/
|
|
41
|
+
type ContractRow = {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
created_at: string;
|
|
45
|
+
/** An `array` property, which codegen emits as `Array<X>`. */
|
|
46
|
+
tags: string[];
|
|
47
|
+
age: number;
|
|
48
|
+
deleted_at: string | null;
|
|
49
|
+
};
|
|
50
|
+
/** A to-many relation, which codegen emits as `Array<TargetRow>`. */
|
|
51
|
+
type TagRow = {
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
};
|
|
55
|
+
type PostRow = {
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
tags: TagRow[];
|
|
59
|
+
};
|
|
60
|
+
/** The documented relevance sort must compile. */
|
|
61
|
+
export declare const orderByScore: FindParams<ContractRow>;
|
|
62
|
+
/** An ordinary column must keep compiling. */
|
|
63
|
+
export declare const orderByColumn: FindParams<ContractRow>;
|
|
64
|
+
/**
|
|
65
|
+
* A column that does not exist must still be refused. Widening `orderBy` to
|
|
66
|
+
* `string` would have fixed the `_score` error and silently given up this,
|
|
67
|
+
* turning every typo into an unsorted 200 in production.
|
|
68
|
+
*/
|
|
69
|
+
export declare const orderByTypo: FindParams<ContractRow>;
|
|
70
|
+
export declare const fluentScore: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
71
|
+
export declare const fluentColumn: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
72
|
+
export declare const fluentTypo: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
73
|
+
/** Vector search must be reachable from the builder, and chain. */
|
|
74
|
+
export declare const fluentVector: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
75
|
+
/**
|
|
76
|
+
* `array-contains` takes an **element** of the column, not the column.
|
|
77
|
+
*
|
|
78
|
+
* This was the second `_score`: documented in `docs/sdk/querying.md`, accepted
|
|
79
|
+
* by the runtime, and a compile error on a generated SDK — because
|
|
80
|
+
* `WhereValue<T> = T | T[] | null` was one value type for all sixteen
|
|
81
|
+
* operators, so on `tags: string[]` it wanted a `string[]`. The spelling that
|
|
82
|
+
* did compile, `["featured"]`, builds `@> ARRAY[$1]` with the whole array bound
|
|
83
|
+
* as the single element and matches nothing, forever, with no error anywhere.
|
|
84
|
+
*/
|
|
85
|
+
export declare const fluentArrayContains: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
86
|
+
export declare const fluentArrayContainsWrapped: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
87
|
+
/**
|
|
88
|
+
* Same defect, and the case the relation compiler was specifically built for:
|
|
89
|
+
* a to-many relation is emitted as `Array<TargetRow>`, and the compiler answers
|
|
90
|
+
* `array-contains` on it by comparing **ids**. So the id must be accepted even
|
|
91
|
+
* though it is not the element type.
|
|
92
|
+
*/
|
|
93
|
+
export declare const fluentRelationContains: (qb: SDKQueryBuilderInterface<PostRow>, tagId: string) => SDKQueryBuilderInterface<PostRow>;
|
|
94
|
+
export declare const fluentRelationIn: (qb: SDKQueryBuilderInterface<PostRow>, tagIds: string[]) => SDKQueryBuilderInterface<PostRow>;
|
|
95
|
+
export declare const fluentRelationTypo: (qb: SDKQueryBuilderInterface<PostRow>) => SDKQueryBuilderInterface<PostRow>;
|
|
96
|
+
/** The list operators take a list of elements — or one, read as a one-element list. */
|
|
97
|
+
export declare const fluentInList: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
98
|
+
export declare const fluentInScalar: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
99
|
+
export declare const fluentInNested: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
100
|
+
/** A comparison takes one value. `eq(column, ["a","b"])` is not a query anyone meant. */
|
|
101
|
+
export declare const fluentEqArray: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
102
|
+
/**
|
|
103
|
+
* A pattern is a string on every column type. The driver casts, so refusing
|
|
104
|
+
* `"%3%"` on a numeric column was the type being stricter than the runtime.
|
|
105
|
+
*/
|
|
106
|
+
export declare const fluentLikeOnNumber: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
107
|
+
/** The null operators ignore their value; `null` is the conventional spelling. */
|
|
108
|
+
export declare const fluentIsNull: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
109
|
+
/**
|
|
110
|
+
* A caller holding an unnarrowed operator — a dynamic filter UI — must keep
|
|
111
|
+
* compiling. `WhereValueFor` distributes over the operator, so this is the
|
|
112
|
+
* union of every branch rather than a `never`.
|
|
113
|
+
*/
|
|
114
|
+
export declare const fluentDynamicOp: (qb: SDKQueryBuilderInterface<ContractRow>, op: WhereFilterOp) => SDKQueryBuilderInterface<ContractRow>;
|
|
115
|
+
/** Two conditions on one column: the shape the Mongo compiler used to drop. */
|
|
116
|
+
export declare const fluentRange: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
117
|
+
/** The object form must accept exactly what the fluent form accepts. */
|
|
118
|
+
export declare const paramsArrayContains: FindParams<ContractRow>;
|
|
119
|
+
/** …and the array-of-tuples form the builder produces from two `.where()` calls. */
|
|
120
|
+
export declare const paramsRange: FindParams<ContractRow>;
|
|
121
|
+
/**
|
|
122
|
+
* Sorting by relevance and then being unable to read it was the other half of
|
|
123
|
+
* the same bug — the e2e cast around it, which should have been the tell.
|
|
124
|
+
*/
|
|
125
|
+
export declare const readComputed: (result: FindResult<ContractRow>) => {
|
|
126
|
+
score: number | undefined;
|
|
127
|
+
distance: number | undefined;
|
|
128
|
+
title: string;
|
|
129
|
+
};
|
|
130
|
+
/** Widening the row must not have turned it into `any`. */
|
|
131
|
+
export declare const readUnknown: (result: FindResult<ContractRow>) => any;
|
|
132
|
+
/**
|
|
133
|
+
* A result row must stay assignable to `Record<string, unknown>`.
|
|
134
|
+
*
|
|
135
|
+
* Widening the row to `M & QueryComputedFields` broke this in seven places in
|
|
136
|
+
* one downstream app, because `QueryComputedFields` was first written as an
|
|
137
|
+
* `interface`: TypeScript grants an implicit index signature to a type alias
|
|
138
|
+
* and withholds it from an interface, so the intersection stopped overlapping
|
|
139
|
+
* with `Record<string, unknown>` and every `as Record<string, unknown>` cast
|
|
140
|
+
* became an error. Nothing in this repo casts a row that way, which is why
|
|
141
|
+
* nothing here noticed.
|
|
142
|
+
*/
|
|
143
|
+
export declare const rowStaysIndexable: (result: FindResult<ContractRow>) => Record<string, unknown>[];
|
|
144
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FindResult, LogicalCondition, SDKCollectionClient, SDKQueryBuilderInterface, WhereFilterOp,
|
|
1
|
+
import { FindResult, LogicalCondition, SDKCollectionClient, SDKQueryBuilderInterface, WhereFilterOp, WhereValueFor, type ComputedSortField } from "@rebasepro/types";
|
|
2
2
|
/**
|
|
3
3
|
* SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
|
|
4
4
|
* Entity-wrapped results (`FindResponse<M>`).
|
|
@@ -21,12 +21,12 @@ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<
|
|
|
21
21
|
* @example
|
|
22
22
|
* client.data.users.where('age', '>=', 18).find()
|
|
23
23
|
*/
|
|
24
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
24
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
|
|
25
25
|
where(logicalCondition: LogicalCondition): this;
|
|
26
26
|
/**
|
|
27
27
|
* Order the results by a specific column.
|
|
28
28
|
*/
|
|
29
|
-
orderBy(column: keyof M & string, direction?: "asc" | "desc"): this;
|
|
29
|
+
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
|
|
30
30
|
/**
|
|
31
31
|
* Limit the number of results returned.
|
|
32
32
|
*/
|
|
@@ -37,8 +37,50 @@ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<
|
|
|
37
37
|
offset(count: number): this;
|
|
38
38
|
/**
|
|
39
39
|
* Set a free-text search string if supported by the backend.
|
|
40
|
+
*
|
|
41
|
+
* By default this is a substring match across the collection's top-level
|
|
42
|
+
* string properties. A Postgres collection that declares a `search` block
|
|
43
|
+
* gets ranked full-text matching over the fields it named instead, and each
|
|
44
|
+
* row comes back with a `_score` you can sort on:
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* Pass `{ explain: true }` to have each row report which of the declared
|
|
51
|
+
* fields matched, with a highlighted snippet, on `_matches`:
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
|
|
55
|
+
* data[0]._matches
|
|
56
|
+
* // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
search(searchString: string, options?: {
|
|
60
|
+
explain?: boolean;
|
|
61
|
+
}): this;
|
|
62
|
+
/**
|
|
63
|
+
* Order rows by nearest-neighbour distance to `vector`.
|
|
64
|
+
*
|
|
65
|
+
* The server has supported this from the REST layer since vectors landed;
|
|
66
|
+
* this is the SDK reaching it. Results come back closest-first with a
|
|
67
|
+
* `_distance` on each row, and any `where` / `orderBy` on the same query is
|
|
68
|
+
* a filter applied before the ordering — distance decides the order.
|
|
69
|
+
*
|
|
70
|
+
* You supply the query vector. Rebase stores and searches embeddings; it
|
|
71
|
+
* does not produce them, so this is where whatever model you already use
|
|
72
|
+
* for the stored vectors gets called.
|
|
73
|
+
*
|
|
74
|
+
* @param property - Name of the `vector` property to compare against.
|
|
75
|
+
* @param vector - The query embedding. Its length must match the property's
|
|
76
|
+
* declared `dimensions`, or the server answers 400.
|
|
77
|
+
* @example
|
|
78
|
+
* client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
|
|
40
79
|
*/
|
|
41
|
-
|
|
80
|
+
vectorSearch(property: string, vector: number[], options?: {
|
|
81
|
+
distance?: "cosine" | "l2" | "inner_product";
|
|
82
|
+
threshold?: number;
|
|
83
|
+
}): this;
|
|
42
84
|
/**
|
|
43
85
|
* Include related entities in the response.
|
|
44
86
|
* Relations will be populated with full data instead of just IDs.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.1-canary.g06dbe5b",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"./package.json": "./package.json"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@rebasepro/common": "0.13.
|
|
33
|
-
"@rebasepro/
|
|
34
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/common": "0.13.1-canary.g06dbe5b",
|
|
33
|
+
"@rebasepro/types": "0.13.1-canary.g06dbe5b",
|
|
34
|
+
"@rebasepro/utils": "0.13.1-canary.g06dbe5b"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createAuth } from "./auth";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What happens to an error thrown by the app's own auth listener.
|
|
7
|
+
*
|
|
8
|
+
* `emit` isolates listeners from each other, which is right — one bad handler
|
|
9
|
+
* must not stop the rest from being told about a sign-in. It also discarded the
|
|
10
|
+
* error entirely, which is not: the throw came from the *caller's* code, and it
|
|
11
|
+
* vanished with nothing in the console, so a broken `onAuthStateChange` handler
|
|
12
|
+
* looked like an event that never fired.
|
|
13
|
+
*
|
|
14
|
+
* The socket in this same package already reports these
|
|
15
|
+
* ("Error in channel handler:"). This was the one listener loop that did not.
|
|
16
|
+
*/
|
|
17
|
+
function transport(): Transport {
|
|
18
|
+
return {
|
|
19
|
+
request: jest.fn<any>().mockResolvedValue({}),
|
|
20
|
+
setToken: jest.fn(),
|
|
21
|
+
setAuthTokenGetter: jest.fn(),
|
|
22
|
+
setOnUnauthorized: jest.fn(),
|
|
23
|
+
baseUrl: "http://localhost:3000",
|
|
24
|
+
apiPath: "/api",
|
|
25
|
+
fetchFn: globalThis.fetch,
|
|
26
|
+
getHeaders: () => ({}),
|
|
27
|
+
resolveToken: jest.fn<any>().mockResolvedValue(null)
|
|
28
|
+
} as unknown as Transport;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("auth state listeners", () => {
|
|
32
|
+
let error: ReturnType<typeof jest.spyOn>;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => { error = jest.spyOn(console, "error").mockImplementation(() => { /* quiet */ }); });
|
|
35
|
+
afterEach(() => error.mockRestore());
|
|
36
|
+
|
|
37
|
+
it("keeps telling the other listeners when one throws", () => {
|
|
38
|
+
const auth = createAuth(transport());
|
|
39
|
+
const second = jest.fn();
|
|
40
|
+
|
|
41
|
+
auth.onAuthStateChange(() => { throw new Error("handler blew up"); });
|
|
42
|
+
auth.onAuthStateChange(second);
|
|
43
|
+
auth.signOut();
|
|
44
|
+
|
|
45
|
+
expect(second).toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reports the throw instead of discarding it", () => {
|
|
49
|
+
const auth = createAuth(transport());
|
|
50
|
+
auth.onAuthStateChange(() => { throw new Error("handler blew up"); });
|
|
51
|
+
|
|
52
|
+
auth.signOut();
|
|
53
|
+
|
|
54
|
+
const said = error.mock.calls.map((c: unknown[]) => c.map(String).join(" ")).join("\n");
|
|
55
|
+
expect(said).toMatch(/handler blew up/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createAuth, createMemoryStorage } from "./auth";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How far ahead the token refresh is allowed to be scheduled.
|
|
7
|
+
*
|
|
8
|
+
* `scheduleRefresh` computes `(expiresAt - REFRESH_BUFFER_MS) - Date.now()` and
|
|
9
|
+
* hands it to `setTimeout`, with a floor for the past and no ceiling. Node and
|
|
10
|
+
* every browser store that delay in a **32-bit signed integer**: hand them more
|
|
11
|
+
* than 2,147,483,647 ms — about 24.8 days — and the timer does not wait, it
|
|
12
|
+
* clamps to 1 ms and fires immediately.
|
|
13
|
+
*
|
|
14
|
+
* `auth.accessExpiresIn` is configurable and defaults to `"1h"`, so this is
|
|
15
|
+
* dormant on a default deployment. Set it to `"30d"` — an ordinary choice for an
|
|
16
|
+
* internal tool or a kiosk — and every signed-in browser refreshes at once,
|
|
17
|
+
* receives a token expiring another 30 days out, schedules again, overflows
|
|
18
|
+
* again: a hot loop against `/auth/refresh`, one per open tab.
|
|
19
|
+
*
|
|
20
|
+
* A floor without a ceiling is the tell. Someone already knew the input was
|
|
21
|
+
* untrusted and bounded one side of it. (Class 23.)
|
|
22
|
+
*/
|
|
23
|
+
const MAX_DELAY = 2_147_483_647;
|
|
24
|
+
|
|
25
|
+
function transport(): Transport {
|
|
26
|
+
return {
|
|
27
|
+
request: jest.fn<any>().mockResolvedValue({}),
|
|
28
|
+
setToken: jest.fn(),
|
|
29
|
+
setAuthTokenGetter: jest.fn(),
|
|
30
|
+
setOnUnauthorized: jest.fn(),
|
|
31
|
+
baseUrl: "http://localhost:3000",
|
|
32
|
+
apiPath: "/api",
|
|
33
|
+
fetchFn: jest.fn<any>().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }),
|
|
34
|
+
getHeaders: () => ({}),
|
|
35
|
+
resolveToken: jest.fn<any>().mockResolvedValue(null)
|
|
36
|
+
} as unknown as Transport;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Storage already holding a session expiring `days` from now. */
|
|
40
|
+
function storageWithSession(days: number) {
|
|
41
|
+
const storage = createMemoryStorage();
|
|
42
|
+
storage.setItem("rebase_auth", JSON.stringify({
|
|
43
|
+
accessToken: "at",
|
|
44
|
+
refreshToken: "rt",
|
|
45
|
+
expiresAt: Date.now() + days * 24 * 60 * 60 * 1000,
|
|
46
|
+
user: { uid: "u1" }
|
|
47
|
+
}));
|
|
48
|
+
return storage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("scheduled token refresh", () => {
|
|
52
|
+
let delays: number[];
|
|
53
|
+
let realSetTimeout: typeof setTimeout;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
delays = [];
|
|
57
|
+
realSetTimeout = globalThis.setTimeout;
|
|
58
|
+
globalThis.setTimeout = ((fn: () => void, ms?: number) => {
|
|
59
|
+
if (typeof ms === "number") delays.push(ms);
|
|
60
|
+
return realSetTimeout(() => { /* never run in this test */ }, 0);
|
|
61
|
+
}) as unknown as typeof setTimeout;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => { globalThis.setTimeout = realSetTimeout; });
|
|
65
|
+
|
|
66
|
+
it("schedules a normal one-hour token in the ordinary way", () => {
|
|
67
|
+
createAuth(transport(), { storage: storageWithSession(1 / 24) });
|
|
68
|
+
|
|
69
|
+
const scheduled = delays.filter(d => d > 1000);
|
|
70
|
+
expect(scheduled.length).toBeGreaterThan(0);
|
|
71
|
+
expect(Math.max(...scheduled)).toBeLessThanOrEqual(MAX_DELAY);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("never asks setTimeout for a delay it cannot hold", () => {
|
|
75
|
+
// 30 days: 2,592,000,000 ms, past the 32-bit ceiling.
|
|
76
|
+
createAuth(transport(), { storage: storageWithSession(30) });
|
|
77
|
+
|
|
78
|
+
for (const d of delays) expect(d).toBeLessThanOrEqual(MAX_DELAY);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not schedule a refresh for one millisecond from now", () => {
|
|
82
|
+
// The symptom of the overflow: the clamp makes it immediate, and the
|
|
83
|
+
// refresh that follows schedules another one just as far out.
|
|
84
|
+
createAuth(transport(), { storage: storageWithSession(90) });
|
|
85
|
+
|
|
86
|
+
const immediate = delays.filter(d => d > 0 && d < 1000);
|
|
87
|
+
expect(immediate).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -89,6 +89,11 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
89
89
|
|
|
90
90
|
const STORAGE_KEY = "rebase_auth";
|
|
91
91
|
const REFRESH_BUFFER_MS = 120000;
|
|
92
|
+
/**
|
|
93
|
+
* The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
|
|
94
|
+
* Anything larger is silently clamped to 1ms by Node and every browser.
|
|
95
|
+
*/
|
|
96
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
92
97
|
// Auto-refresh resilience: retry transient failures with exponential backoff
|
|
93
98
|
// (1s, 2s, 4s, … capped) before giving up and signing out.
|
|
94
99
|
const MAX_REFRESH_RETRIES = 5;
|
|
@@ -129,7 +134,16 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
129
134
|
|
|
130
135
|
function emit(event: AuthChangeEvent, session: RebaseSession | null) {
|
|
131
136
|
for (const fn of listeners) {
|
|
132
|
-
try {
|
|
137
|
+
try {
|
|
138
|
+
fn(event, session);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// Isolated so one bad handler cannot stop the rest being told —
|
|
141
|
+
// but reported, because the throw came from the caller's own
|
|
142
|
+
// code and discarding it made a broken `onAuthStateChange`
|
|
143
|
+
// handler look like an event that never fired. The socket in
|
|
144
|
+
// this package already reports handler errors this way.
|
|
145
|
+
console.error("Error in auth state change listener:", e);
|
|
146
|
+
}
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
|
|
@@ -260,6 +274,21 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
260
274
|
return;
|
|
261
275
|
}
|
|
262
276
|
|
|
277
|
+
// `setTimeout` holds its delay in a 32-bit signed integer. Past
|
|
278
|
+
// ~24.8 days it does not wait — it clamps to 1ms and fires at once. The
|
|
279
|
+
// refresh would then land, receive a token expiring just as far out,
|
|
280
|
+
// schedule again and overflow again: a hot loop against
|
|
281
|
+
// `/auth/refresh`, one per open tab.
|
|
282
|
+
//
|
|
283
|
+
// `auth.accessExpiresIn` is configurable and defaults to "1h", so this
|
|
284
|
+
// is dormant on a default deployment and immediate on `"30d"` — an
|
|
285
|
+
// ordinary setting for an internal tool. Re-arm instead of refreshing:
|
|
286
|
+
// sleep the maximum, then work out again how long is left.
|
|
287
|
+
if (delay > MAX_TIMER_DELAY_MS) {
|
|
288
|
+
refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
263
292
|
refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(0); }, delay);
|
|
264
293
|
}
|
|
265
294
|
|