@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.
@@ -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 {
@@ -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, WhereValue, type ComputedSortField } from "@rebasepro/types";
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: WhereFilterOp, value: WhereValue<M[K]>): this;
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.
@@ -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 is re-exported from the
17
- * package root only because the `@rebasepro/client-postgres` driver
18
- * instantiates it directly; its surface may change without a major bump.
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.13.1-canary.gf57a27e",
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/common": "0.13.1-canary.gf57a27e",
33
- "@rebasepro/utils": "0.13.1-canary.gf57a27e",
34
- "@rebasepro/types": "0.13.1-canary.gf57a27e"
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
- WhereValue,
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 WhereValue<M[keyof M & string]>);
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) because the `client-postgres`
89
- // driver constructs it directly. Not a stable app-facing API.
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 { ConnectivityMonitor, isNetworkError, isRetryableError } from "./offline-connectivity";
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 error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
45
- return false;
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
- return error.code === "23505" || error.status === 409;
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", () => {
@@ -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
 
@@ -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);