@rebasepro/client 0.13.1-canary.gf57a27e → 0.14.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/index.es.js +169 -11
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +41 -0
- package/dist/query-contract.types.d.ts +61 -1
- package/dist/sdk_query_builder.d.ts +2 -2
- package/dist/websocket.d.ts +5 -3
- package/package.json +4 -4
- package/src/collection.ts +2 -2
- package/src/index.ts +2 -2
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +88 -0
- package/src/offline-query.ts +83 -0
- package/src/offline.test.ts +66 -1
- package/src/offline.ts +48 -6
- package/src/query-contract.types.ts +93 -1
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/sdk_query_builder.ts +2 -2
- package/src/transport.ts +45 -2
- package/src/websocket.ts +21 -3
|
@@ -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
|
@@ -60,7 +60,48 @@ export declare function resolvePagination(params?: FindParams): {
|
|
|
60
60
|
* `include` pulls in rows from other collections that this evaluator never
|
|
61
61
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
62
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}.
|
|
63
85
|
*/
|
|
64
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;
|
|
65
106
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
66
107
|
export declare function runLocalQuery<M extends Record<string, unknown>>(rows: M[], params?: FindParams): FindResult<M>;
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* generated SDK — where `M` is a concrete row type — the documented call was a
|
|
24
24
|
* compile error. Nothing in this repo noticed; a downstream application did.
|
|
25
25
|
*/
|
|
26
|
-
import type { FindParams, FindResult, SDKQueryBuilderInterface } from "@rebasepro/types";
|
|
26
|
+
import type { FindParams, FindResult, SDKQueryBuilderInterface, WhereFilterOp } from "@rebasepro/types";
|
|
27
27
|
/**
|
|
28
28
|
* A row shaped the way a **generated** SDK shapes one: a type alias with a
|
|
29
29
|
* finite key set.
|
|
@@ -42,6 +42,20 @@ type ContractRow = {
|
|
|
42
42
|
id: string;
|
|
43
43
|
title: string;
|
|
44
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[];
|
|
45
59
|
};
|
|
46
60
|
/** The documented relevance sort must compile. */
|
|
47
61
|
export declare const orderByScore: FindParams<ContractRow>;
|
|
@@ -58,6 +72,52 @@ export declare const fluentColumn: (qb: SDKQueryBuilderInterface<ContractRow>) =
|
|
|
58
72
|
export declare const fluentTypo: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
|
|
59
73
|
/** Vector search must be reachable from the builder, and chain. */
|
|
60
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>;
|
|
61
121
|
/**
|
|
62
122
|
* Sorting by relevance and then being unable to read it was the other half of
|
|
63
123
|
* the same bug — the e2e cast around it, which should have been the tell.
|
|
@@ -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,7 +21,7 @@ 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.
|
package/dist/websocket.d.ts
CHANGED
|
@@ -13,9 +13,11 @@ export interface RebaseWebSocketConfig {
|
|
|
13
13
|
*
|
|
14
14
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
15
15
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
16
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
17
|
-
* package root
|
|
18
|
-
*
|
|
16
|
+
* `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
|
|
17
|
+
* package root for the same reason it always was — a data-source driver may
|
|
18
|
+
* instantiate it directly — but nothing in this repo does since
|
|
19
|
+
* `@rebasepro/client-postgres` was removed; its surface may change without a
|
|
20
|
+
* major bump.
|
|
19
21
|
*/
|
|
20
22
|
export declare class RebaseWebSocketClient {
|
|
21
23
|
private websocketUrl;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.0",
|
|
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/
|
|
33
|
-
"@rebasepro/
|
|
34
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/types": "0.14.0",
|
|
33
|
+
"@rebasepro/common": "0.14.0",
|
|
34
|
+
"@rebasepro/utils": "0.14.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
package/src/collection.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
SDKCollectionClient,
|
|
9
9
|
SDKQueryBuilderInterface,
|
|
10
10
|
WhereFilterOp,
|
|
11
|
-
|
|
11
|
+
WhereValueFor,
|
|
12
12
|
WriteOptions,
|
|
13
13
|
type ComputedSortField
|
|
14
14
|
} from "@rebasepro/types";
|
|
@@ -381,7 +381,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
381
381
|
if (typeof columnOrCondition === "object") {
|
|
382
382
|
return builder.where(columnOrCondition);
|
|
383
383
|
}
|
|
384
|
-
return builder.where(columnOrCondition as keyof M & string, operator!, value as
|
|
384
|
+
return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
|
|
385
385
|
},
|
|
386
386
|
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc") {
|
|
387
387
|
return new SDKQueryBuilder<M>(client).orderBy(column, direction);
|
package/src/index.ts
CHANGED
|
@@ -85,8 +85,8 @@ export type {
|
|
|
85
85
|
export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
|
|
86
86
|
|
|
87
87
|
// Realtime: the WebSocket client class is internal to `createRebaseClient()`,
|
|
88
|
-
// but re-exported (see @internal on the class)
|
|
89
|
-
//
|
|
88
|
+
// but re-exported (see @internal on the class) so a data-source driver can
|
|
89
|
+
// construct it directly. Not a stable app-facing API.
|
|
90
90
|
export { RebaseWebSocketClient } from "./websocket";
|
|
91
91
|
export { RebaseRealtimeChannel } from "./realtime-channel";
|
|
92
92
|
export type {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConnectivityMonitor,
|
|
3
|
+
isDuplicateKeyError,
|
|
4
|
+
isNetworkError,
|
|
5
|
+
isRetryableError
|
|
6
|
+
} from "./offline-connectivity";
|
|
2
7
|
import { RebaseApiError } from "./transport";
|
|
3
8
|
|
|
4
9
|
/**
|
|
@@ -35,6 +40,35 @@ describe("network error classification", () => {
|
|
|
35
40
|
// blip, and retrying it forever jams every write queued behind it.
|
|
36
41
|
expect(isRetryableError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
|
|
37
42
|
});
|
|
43
|
+
|
|
44
|
+
it("retries a write the server is still answering, and only that 409", () => {
|
|
45
|
+
// The server's own message says to retry — "its result will be
|
|
46
|
+
// replayed" — and a key whose claim outlived the process that took it
|
|
47
|
+
// is refused until the lease expires. Giving up instead rolls back a
|
|
48
|
+
// write that retrying would have completed.
|
|
49
|
+
expect(isRetryableError(new RebaseApiError("in progress", {
|
|
50
|
+
status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
51
|
+
}))).toBe(true);
|
|
52
|
+
// Every other 409 is a real conflict and stays fatal.
|
|
53
|
+
expect(isRetryableError(new RebaseApiError("row exists", { status: 409, code: "23505" }))).toBe(false);
|
|
54
|
+
expect(isRetryableError(new RebaseApiError("conflict", { status: 409 }))).toBe(false);
|
|
55
|
+
expect(isRetryableError(new RebaseApiError("reused", {
|
|
56
|
+
status: 422, code: "IDEMPOTENCY_KEY_REUSED"
|
|
57
|
+
}))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not read an unanswered write as a row that is already there", () => {
|
|
61
|
+
// The status alone cannot decide it. Read as a duplicate, the queue
|
|
62
|
+
// went looking for a row that was never written, found nothing,
|
|
63
|
+
// concluded there was nothing left to do and deleted the write.
|
|
64
|
+
expect(isDuplicateKeyError(new RebaseApiError("in progress", {
|
|
65
|
+
status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
66
|
+
}))).toBe(false);
|
|
67
|
+
|
|
68
|
+
expect(isDuplicateKeyError(new RebaseApiError("dup", { status: 400, code: "23505" }))).toBe(true);
|
|
69
|
+
expect(isDuplicateKeyError(new RebaseApiError("conflict", { status: 409 }))).toBe(true);
|
|
70
|
+
expect(isDuplicateKeyError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
|
|
71
|
+
});
|
|
38
72
|
});
|
|
39
73
|
|
|
40
74
|
describe("ConnectivityMonitor", () => {
|
|
@@ -38,11 +38,37 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
38
38
|
*/
|
|
39
39
|
const RETRYABLE_STATUSES = new Set([408, 425, 429, 502, 503, 504]);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The server holds this key for a request it has not answered yet.
|
|
43
|
+
*
|
|
44
|
+
* It is a 409 like a duplicate row is a 409, and nothing but the code separates
|
|
45
|
+
* them — one means "your write is already there", the other means "your write
|
|
46
|
+
* may not have happened at all, ask again".
|
|
47
|
+
*/
|
|
48
|
+
const IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Is the server still answering an earlier attempt of this same write?
|
|
52
|
+
*
|
|
53
|
+
* The only correct response is to ask again — which is exactly what the
|
|
54
|
+
* server's own message says, and exactly what this SDK used not to do.
|
|
55
|
+
*/
|
|
56
|
+
export function isIdempotencyInProgressError(error: unknown): boolean {
|
|
57
|
+
return error instanceof RebaseApiError
|
|
58
|
+
&& error.status === 409
|
|
59
|
+
&& error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
/** Is this failure worth another attempt later? */
|
|
42
63
|
export function isRetryableError(error: unknown): boolean {
|
|
43
64
|
if (isNetworkError(error)) return true;
|
|
44
|
-
if (error instanceof RebaseApiError) return
|
|
45
|
-
|
|
65
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
66
|
+
// The one 409 that resolves on its own. A key whose claim outlived the
|
|
67
|
+
// request that took it — the process was killed between the write and the
|
|
68
|
+
// answer — is refused until the claim's lease expires, and giving up on it
|
|
69
|
+
// means dropping a write that retrying would have completed.
|
|
70
|
+
if (isIdempotencyInProgressError(error)) return true;
|
|
71
|
+
return error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
/**
|
|
@@ -55,10 +81,16 @@ export function isRetryableError(error: unknown): boolean {
|
|
|
55
81
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
56
82
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
57
83
|
* the server can only be rejecting it because the first attempt actually landed.
|
|
84
|
+
*
|
|
85
|
+
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
86
|
+
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
87
|
+
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
88
|
+
* concluded there was nothing left to do and deleted the write from the queue.
|
|
58
89
|
*/
|
|
59
90
|
export function isDuplicateKeyError(error: unknown): boolean {
|
|
60
91
|
if (!(error instanceof RebaseApiError)) return false;
|
|
61
|
-
|
|
92
|
+
if (error.code === "23505") return true;
|
|
93
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
export interface ConnectivityOptions {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
compareValues,
|
|
3
3
|
isExactlyEvaluable,
|
|
4
|
+
isLocallySortable,
|
|
4
5
|
looseEquals,
|
|
5
6
|
matchesLogical,
|
|
6
7
|
matchesOperator,
|
|
@@ -255,6 +256,93 @@ describe("local query engine", () => {
|
|
|
255
256
|
expect(isExactlyEvaluable({ include: ["author"] })).toBe(false);
|
|
256
257
|
expect(isExactlyEvaluable({ include: [] })).toBe(true);
|
|
257
258
|
});
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* An ordering comparison is answered here by `compareValues`, which
|
|
262
|
+
* falls back to an `Intl.Collator`, and by Postgres using the
|
|
263
|
+
* *database's* collation — a property of the server this process has
|
|
264
|
+
* never been told. `'apple' < 'Banana'` is false under the C collation
|
|
265
|
+
* and true under `en_US.UTF-8`; the collator says true. So the two
|
|
266
|
+
* select different sets, in whichever direction the deployment happened
|
|
267
|
+
* to be created.
|
|
268
|
+
*/
|
|
269
|
+
it("refuses an ordering comparison, whose answer depends on the server's collation", () => {
|
|
270
|
+
for (const op of ["<", "<=", ">", ">="] as const) {
|
|
271
|
+
expect({ op, exact: isExactlyEvaluable({ where: { name: [op, "Banana"] } }) })
|
|
272
|
+
.toEqual({ op, exact: false });
|
|
273
|
+
}
|
|
274
|
+
// Equality, membership and pattern matching are unaffected — all
|
|
275
|
+
// verified against a real database in
|
|
276
|
+
// `server-postgres/test/e2e/offline-query-agreement.test.ts`.
|
|
277
|
+
for (const op of ["==", "!=", "in", "not-in", "like", "ilike"] as const) {
|
|
278
|
+
expect({ op, exact: isExactlyEvaluable({ where: { name: [op, "x"] } }) })
|
|
279
|
+
.toEqual({ op, exact: true });
|
|
280
|
+
}
|
|
281
|
+
expect(isExactlyEvaluable({ where: { a: ["is-null", null] } })).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("refuses a numeric range too, because the operand type does not settle it", () => {
|
|
285
|
+
// `compareValues` deliberately reads numeric strings as numbers, so
|
|
286
|
+
// it cannot tell an integer column from a text column of digits —
|
|
287
|
+
// and on the latter Postgres orders "10" before "9" while this
|
|
288
|
+
// orders 9 before 10. Conservative on purpose; the schema would be
|
|
289
|
+
// needed to do better.
|
|
290
|
+
expect(isExactlyEvaluable({ where: { qty: [">", 10] } })).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("looks inside and/or groups, not just the top-level where", () => {
|
|
294
|
+
expect(isExactlyEvaluable({
|
|
295
|
+
logical: { type: "or", conditions: [
|
|
296
|
+
{ column: "a", operator: "==", value: 1 },
|
|
297
|
+
{ column: "b", operator: "==", value: 2 }
|
|
298
|
+
] }
|
|
299
|
+
} as never)).toBe(true);
|
|
300
|
+
expect(isExactlyEvaluable({
|
|
301
|
+
logical: { type: "or", conditions: [
|
|
302
|
+
{ column: "a", operator: "==", value: 1 },
|
|
303
|
+
{ type: "and", conditions: [{ column: "b", operator: "<", value: "m" }] }
|
|
304
|
+
] }
|
|
305
|
+
} as never)).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("still refuses several conditions on one field when any of them orders", () => {
|
|
309
|
+
expect(isExactlyEvaluable({ where: { a: [["==", 1], ["<", "m"]] } } as never)).toBe(false);
|
|
310
|
+
expect(isExactlyEvaluable({ where: { a: [["==", 1], ["!=", 2]] } } as never)).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("isLocallySortable", () => {
|
|
315
|
+
/**
|
|
316
|
+
* Asked of the rows rather than the query, because unlike a filter this
|
|
317
|
+
* one is decidable from the data in hand: the collator is reachable
|
|
318
|
+
* only when a value cannot be read as a number.
|
|
319
|
+
*/
|
|
320
|
+
it("accepts a column this page holds only as numbers", () => {
|
|
321
|
+
expect(isLocallySortable([{ id: 1, n: 3 }, { id: 2, n: 10 }], ["n", "asc"])).toBe(true);
|
|
322
|
+
// The wire's type erasure is already undone by `compareValues`.
|
|
323
|
+
expect(isLocallySortable([{ id: 1, n: "3" }, { id: 2, n: "10" }], ["n", "asc"])).toBe(true);
|
|
324
|
+
// Dates normalise to instants before any comparison happens.
|
|
325
|
+
expect(isLocallySortable(
|
|
326
|
+
[{ id: 1, at: new Date(1) }, { id: 2, at: new Date(2) }], ["at", "desc"]
|
|
327
|
+
)).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("refuses a column holding text, whose order is the database's to decide", () => {
|
|
331
|
+
expect(isLocallySortable([{ id: 1, name: "apple" }, { id: 2, name: "Banana" }], ["name", "asc"]))
|
|
332
|
+
.toBe(false);
|
|
333
|
+
// One text value is enough — a column is one type, and the page that
|
|
334
|
+
// happens to be cached does not get to vote.
|
|
335
|
+
expect(isLocallySortable([{ id: 1, n: 3 }, { id: 2, n: "x" }], ["n", "asc"])).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("ignores nulls, which are ordered by an explicit rule", () => {
|
|
339
|
+
expect(isLocallySortable([{ id: 1, n: null }, { id: 2, n: 5 }], ["n", "asc"])).toBe(true);
|
|
340
|
+
expect(isLocallySortable([{ id: 1 }, { id: 2 }], ["missing", "asc"])).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("is vacuously true with no sort", () => {
|
|
344
|
+
expect(isLocallySortable([{ id: 1, name: "a" }], undefined)).toBe(true);
|
|
345
|
+
});
|
|
258
346
|
});
|
|
259
347
|
|
|
260
348
|
describe("primitives", () => {
|
package/src/offline-query.ts
CHANGED
|
@@ -347,6 +347,28 @@ export function resolvePagination(params?: FindParams): { limit: number; offset:
|
|
|
347
347
|
return { limit, offset };
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
|
|
351
|
+
const ORDERING_OPS = new Set<WhereFilterOp>(["<", "<=", ">", ">="]);
|
|
352
|
+
|
|
353
|
+
/** Does any condition in this `where` clause order its operands? */
|
|
354
|
+
function whereOrders(where: FilterValues<string> | undefined): boolean {
|
|
355
|
+
if (!where) return false;
|
|
356
|
+
for (const condition of Object.values(where)) {
|
|
357
|
+
const tuples = isTuple(condition) ? [condition] : (condition as unknown[]).filter(isTuple);
|
|
358
|
+
if (tuples.some(([op]) => ORDERING_OPS.has(op))) return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
364
|
+
function logicalOrders(condition: LogicalCondition | FilterCondition | undefined, depth = 0): boolean {
|
|
365
|
+
if (!condition || depth > 32) return false;
|
|
366
|
+
if ("type" in condition) {
|
|
367
|
+
return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
|
|
368
|
+
}
|
|
369
|
+
return ORDERING_OPS.has(condition.operator);
|
|
370
|
+
}
|
|
371
|
+
|
|
350
372
|
/**
|
|
351
373
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
352
374
|
* assuming the cache holds every row of the collection?
|
|
@@ -354,6 +376,28 @@ export function resolvePagination(params?: FindParams): { limit: number; offset:
|
|
|
354
376
|
* `include` pulls in rows from other collections that this evaluator never
|
|
355
377
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
356
378
|
* best effort rather than an equivalent one.
|
|
379
|
+
*
|
|
380
|
+
* **Ordering comparisons are refused, and that is the interesting one.**
|
|
381
|
+
* `compareValues` falls back to an `Intl.Collator` for operands it cannot read
|
|
382
|
+
* as numbers or instants. PostgreSQL orders text by the *database's* collation,
|
|
383
|
+
* which is a property of the server this process has never been told: under the
|
|
384
|
+
* C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
|
|
385
|
+
* and the collator says true. So `["<", "Banana"]` selects a different set here
|
|
386
|
+
* than it does there — silently, and in whichever direction the deployment
|
|
387
|
+
* happens to have been created.
|
|
388
|
+
*
|
|
389
|
+
* The refusal covers *every* ordering comparison rather than only the ones with
|
|
390
|
+
* a string operand, because the operand type does not settle it: a numeric
|
|
391
|
+
* bound against a text column (`["<", 10]` on a `varchar`) also reaches the
|
|
392
|
+
* collator, and nothing in `params` says what the column holds. Conservative on
|
|
393
|
+
* purpose — the cost is that a query combining an ordering filter with
|
|
394
|
+
* *unsynced local writes* stops placing those writes optimistically, which is a
|
|
395
|
+
* degraded answer rather than a wrong one. Claiming exactness we do not have is
|
|
396
|
+
* the other way round.
|
|
397
|
+
*
|
|
398
|
+
* This says nothing about ordering *results*; that is a separate claim with a
|
|
399
|
+
* separate answer, because a sort changes which rows come first and not which
|
|
400
|
+
* rows match. See {@link isLocallySortable}.
|
|
357
401
|
*/
|
|
358
402
|
export function isExactlyEvaluable(params?: FindParams): boolean {
|
|
359
403
|
if (!params) return true;
|
|
@@ -364,6 +408,45 @@ export function isExactlyEvaluable(params?: FindParams): boolean {
|
|
|
364
408
|
// nearest of what happens to be cached while looking like the nearest there
|
|
365
409
|
// are — a wrong answer that is indistinguishable from a right one.
|
|
366
410
|
if (params.vectorSearch) return false;
|
|
411
|
+
if (whereOrders(params.where)) return false;
|
|
412
|
+
if (logicalOrders(params.logical)) return false;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
418
|
+
*
|
|
419
|
+
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
420
|
+
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
421
|
+
* collator only when it cannot read both operands as numbers, and `toComparable`
|
|
422
|
+
* has already turned dates and relations into numbers and ids by then. If every
|
|
423
|
+
* value on the sort column normalises to a number, the collator is unreachable
|
|
424
|
+
* and the local order is the server's order.
|
|
425
|
+
*
|
|
426
|
+
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
427
|
+
* the two cannot be made to agree — and so is a column this page happens to see
|
|
428
|
+
* only as strings, which is the same thing from here.
|
|
429
|
+
*
|
|
430
|
+
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
431
|
+
* ascending, first descending) that matches Postgres and never reaches the
|
|
432
|
+
* comparator.
|
|
433
|
+
*/
|
|
434
|
+
export function isLocallySortable(
|
|
435
|
+
rows: readonly Record<string, unknown>[],
|
|
436
|
+
orderBy?: OrderByTuple
|
|
437
|
+
): boolean {
|
|
438
|
+
if (!orderBy) return true;
|
|
439
|
+
const [field] = orderBy;
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
const value = toComparable(row[field]);
|
|
442
|
+
if (isNullish(value)) continue;
|
|
443
|
+
if (typeof value === "number" || typeof value === "boolean") continue;
|
|
444
|
+
if (typeof value === "bigint") continue;
|
|
445
|
+
// A numeric string is compared as a number, so it is safe too — this is
|
|
446
|
+
// the wire's type erasure, which `compareValues` already undoes.
|
|
447
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
367
450
|
return true;
|
|
368
451
|
}
|
|
369
452
|
|
package/src/offline.test.ts
CHANGED
|
@@ -14,7 +14,7 @@ type Row = Record<string, unknown>;
|
|
|
14
14
|
* online/offline decision logic rather than a mock of it.
|
|
15
15
|
*/
|
|
16
16
|
function createFakeServer() {
|
|
17
|
-
const state = { online: true };
|
|
17
|
+
const state: { online: boolean; rejectNextCreate?: Error; rejectEveryCreate?: Error } = { online: true };
|
|
18
18
|
const tables = new Map<string, Map<string, Row>>();
|
|
19
19
|
const log: { collection: string; op: string; id?: unknown; ids?: unknown[]; upsert?: boolean }[] = [];
|
|
20
20
|
|
|
@@ -41,6 +41,13 @@ function createFakeServer() {
|
|
|
41
41
|
},
|
|
42
42
|
async create(data: Partial<Row>, id?: string | number) {
|
|
43
43
|
assertOnline();
|
|
44
|
+
// Lets a test answer the first replay the way a server does
|
|
45
|
+
// when it is still holding this write's idempotency key.
|
|
46
|
+
const injected = state.rejectNextCreate ?? state.rejectEveryCreate;
|
|
47
|
+
if (injected) {
|
|
48
|
+
state.rejectNextCreate = undefined;
|
|
49
|
+
throw injected;
|
|
50
|
+
}
|
|
44
51
|
const rowId = id ?? data.id ?? `srv-${table(slug).size + 1}`;
|
|
45
52
|
log.push({ collection: slug, op: "create", id: rowId });
|
|
46
53
|
const row = { ...data, id: rowId };
|
|
@@ -311,6 +318,64 @@ describe("OfflineManager", () => {
|
|
|
311
318
|
expect(server.table("posts").get("p9")).toMatchObject({ title: "kept" });
|
|
312
319
|
});
|
|
313
320
|
|
|
321
|
+
it("keeps a write the server is still answering, and lands it on the next flush", async () => {
|
|
322
|
+
// `IDEMPOTENCY_KEY_IN_PROGRESS` means the server holds this write's
|
|
323
|
+
// key and has not answered yet — the row may not exist at all. Read
|
|
324
|
+
// as any other 409 it looked like "your row is already there": the
|
|
325
|
+
// queue looked the row up, found nothing, decided there was nothing
|
|
326
|
+
// left to do and deleted the write. The record survived only in the
|
|
327
|
+
// local store and disappeared at the next reconciliation, silently.
|
|
328
|
+
const server = createFakeServer();
|
|
329
|
+
const onSyncError = jest.fn<(error: Error, mutation: PendingMutation) => void>();
|
|
330
|
+
const { manager, wrap } = createManager(server, { onSyncError });
|
|
331
|
+
|
|
332
|
+
server.state.online = false;
|
|
333
|
+
await wrap("posts").create({ title: "queued" });
|
|
334
|
+
|
|
335
|
+
server.state.online = true;
|
|
336
|
+
server.state.rejectNextCreate = new RebaseApiError("still in progress", {
|
|
337
|
+
status: 409,
|
|
338
|
+
code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const refused = await manager.sync();
|
|
342
|
+
expect(refused).toEqual({ flushed: 0, remaining: 1 });
|
|
343
|
+
expect(onSyncError).not.toHaveBeenCalled();
|
|
344
|
+
expect(server.table("posts").size).toBe(0);
|
|
345
|
+
|
|
346
|
+
// The claim expired (or its request finally answered): the same
|
|
347
|
+
// queued write goes through untouched.
|
|
348
|
+
const settled = await manager.sync();
|
|
349
|
+
expect(settled).toEqual({ flushed: 1, remaining: 0 });
|
|
350
|
+
expect([...server.table("posts").values()]).toMatchObject([{ title: "queued" }]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("outlasts the claim's lease rather than giving up on the default retry budget", async () => {
|
|
354
|
+
// Retries double from a second, so the five a busy server gets are
|
|
355
|
+
// spent inside half a minute — less than the lease the server gives
|
|
356
|
+
// a claim whose process died. Spending them here would roll back
|
|
357
|
+
// the write a few seconds before the key became free again.
|
|
358
|
+
const server = createFakeServer();
|
|
359
|
+
const onSyncError = jest.fn<(error: Error, mutation: PendingMutation) => void>();
|
|
360
|
+
const { manager, wrap } = createManager(server, { onSyncError });
|
|
361
|
+
|
|
362
|
+
server.state.online = false;
|
|
363
|
+
await wrap("posts").create({ title: "queued" });
|
|
364
|
+
|
|
365
|
+
server.state.online = true;
|
|
366
|
+
server.state.rejectEveryCreate = new RebaseApiError("still in progress", {
|
|
367
|
+
status: 409,
|
|
368
|
+
code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
369
|
+
});
|
|
370
|
+
for (let attempt = 0; attempt < 8; attempt++) await manager.sync();
|
|
371
|
+
|
|
372
|
+
expect(onSyncError).not.toHaveBeenCalled();
|
|
373
|
+
expect(await manager.api.pending()).toHaveLength(1);
|
|
374
|
+
|
|
375
|
+
server.state.rejectEveryCreate = undefined;
|
|
376
|
+
expect(await manager.sync()).toEqual({ flushed: 1, remaining: 0 });
|
|
377
|
+
});
|
|
378
|
+
|
|
314
379
|
it("notifies queue-size listeners as writes queue and drain", async () => {
|
|
315
380
|
const server = createFakeServer();
|
|
316
381
|
const { manager, wrap } = createManager(server);
|