@rebasepro/client 0.14.1 → 0.14.2-canary.g27a129e
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 +14 -1
- package/dist/index.es.js.map +1 -1
- package/package.json +4 -4
- package/src/offline-query.test.ts +51 -0
- package/src/offline-query.ts +33 -1
- package/src/transport-baseurl.test.ts +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.2-canary.g27a129e",
|
|
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.14.
|
|
33
|
-
"@rebasepro/
|
|
34
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/common": "0.14.2-canary.g27a129e",
|
|
33
|
+
"@rebasepro/types": "0.14.2-canary.g27a129e",
|
|
34
|
+
"@rebasepro/utils": "0.14.2-canary.g27a129e"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
|
@@ -326,6 +326,31 @@ describe("local query engine", () => {
|
|
|
326
326
|
expect(isExactlyEvaluable({ where: { a: [["==", 1], ["<", "m"]] } } as never)).toBe(false);
|
|
327
327
|
expect(isExactlyEvaluable({ where: { a: [["==", 1], ["!=", 2]] } } as never)).toBe(true);
|
|
328
328
|
});
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A dotted key reaches through a relation — `applications.status` asks
|
|
332
|
+
* about rows in another table. `matchesWhere` reads `row[field]` flat,
|
|
333
|
+
* so the key resolves to `undefined` on every cached row and the
|
|
334
|
+
* condition excludes all of them: a 200 with an empty list, which is
|
|
335
|
+
* indistinguishable from "nothing matched". The cache holds one
|
|
336
|
+
* collection and cannot answer a question about a second.
|
|
337
|
+
*/
|
|
338
|
+
it("refuses a filter that reaches through a relation", () => {
|
|
339
|
+
expect(isExactlyEvaluable({
|
|
340
|
+
where: { "applications.status": ["in", ["applied"]] }
|
|
341
|
+
} as never)).toBe(false);
|
|
342
|
+
// The undotted sibling is unaffected.
|
|
343
|
+
expect(isExactlyEvaluable({ where: { status: ["in", ["applied"]] } } as never)).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("finds a relation path inside an and/or group too", () => {
|
|
347
|
+
expect(isExactlyEvaluable({
|
|
348
|
+
logical: { type: "or", conditions: [
|
|
349
|
+
{ column: "a", operator: "==", value: 1 },
|
|
350
|
+
{ column: "applications.status", operator: "==", value: "applied" }
|
|
351
|
+
] }
|
|
352
|
+
} as never)).toBe(false);
|
|
353
|
+
});
|
|
329
354
|
});
|
|
330
355
|
|
|
331
356
|
describe("isLocallySortable", () => {
|
|
@@ -360,6 +385,32 @@ describe("local query engine", () => {
|
|
|
360
385
|
it("is vacuously true with no sort", () => {
|
|
361
386
|
expect(isLocallySortable([{ id: 1, name: "a" }], undefined)).toBe(true);
|
|
362
387
|
});
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* An aggregate over a relation is computed by the database and is not a
|
|
391
|
+
* field on the row, so every cached row reads `undefined` for it. To
|
|
392
|
+
* the null rule above that looks exactly like a column of nulls — which
|
|
393
|
+
* it would call reproducible, and then hand back rows in id order: a
|
|
394
|
+
* queue sorted by nothing at all, presented as the server's answer.
|
|
395
|
+
*/
|
|
396
|
+
it("refuses an aggregate sort, in either spelling", () => {
|
|
397
|
+
const rows = [{ id: 1, name: "a" }, { id: 2, name: "b" }];
|
|
398
|
+
expect(isLocallySortable(rows, ["min(applications.created_at)", "asc"])).toBe(false);
|
|
399
|
+
expect(isLocallySortable(rows, ["count(applications)", "desc"])).toBe(false);
|
|
400
|
+
expect(isLocallySortable(
|
|
401
|
+
rows,
|
|
402
|
+
[{ relation: "applications", field: "created_at", agg: "min" }, "asc"]
|
|
403
|
+
)).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("refuses a multi-key sort whose second key is an aggregate", () => {
|
|
407
|
+
// A sort the local side can only agree with down to its second
|
|
408
|
+
// column is one it disagrees with.
|
|
409
|
+
expect(isLocallySortable(
|
|
410
|
+
[{ id: 1, n: 1 }, { id: 2, n: 2 }],
|
|
411
|
+
[["n", "asc"], ["count(applications)", "desc"]]
|
|
412
|
+
)).toBe(false);
|
|
413
|
+
});
|
|
363
414
|
});
|
|
364
415
|
|
|
365
416
|
describe("primitives", () => {
|
package/src/offline-query.ts
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
FilterCondition,
|
|
7
7
|
OrderBySpec,
|
|
8
8
|
WhereFilterOp,
|
|
9
|
-
toCanonicalOp
|
|
9
|
+
toCanonicalOp,
|
|
10
|
+
parseRelationAggregateSort
|
|
10
11
|
} from "@rebasepro/types";
|
|
11
12
|
import { FindParams } from "./transport";
|
|
12
13
|
import { normalizeOrderBy, resolveFindWindow } from "@rebasepro/common";
|
|
@@ -435,9 +436,34 @@ export function isExactlyEvaluable(params?: FindParams): boolean {
|
|
|
435
436
|
if (params.vectorSearch) return false;
|
|
436
437
|
if (whereOrders(params.where)) return false;
|
|
437
438
|
if (logicalOrders(params.logical)) return false;
|
|
439
|
+
// A dotted key reaches through a relation — `applications.status` asks
|
|
440
|
+
// about rows in another table. `matchesWhere` reads `row[field]` flat, so
|
|
441
|
+
// the key resolves to `undefined` on every cached row and the condition
|
|
442
|
+
// excludes all of them: a 200 with an empty list, indistinguishable from
|
|
443
|
+
// "nothing matched". The cache holds one collection; it cannot answer a
|
|
444
|
+
// question about a second, so it must not claim to.
|
|
445
|
+
if (whereReachesThroughRelation(params.where)) return false;
|
|
446
|
+
if (logicalReachesThroughRelation(params.logical)) return false;
|
|
438
447
|
return true;
|
|
439
448
|
}
|
|
440
449
|
|
|
450
|
+
/** Does any filter key reach outside this row — `applications.status`? */
|
|
451
|
+
function whereReachesThroughRelation(where: FilterValues<string> | undefined): boolean {
|
|
452
|
+
return where ? Object.keys(where).some((field) => field.includes(".")) : false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
456
|
+
function logicalReachesThroughRelation(
|
|
457
|
+
condition: LogicalCondition | FilterCondition | undefined,
|
|
458
|
+
depth = 0
|
|
459
|
+
): boolean {
|
|
460
|
+
if (!condition || depth > 32) return false;
|
|
461
|
+
if ("type" in condition) {
|
|
462
|
+
return (condition.conditions ?? []).some((c) => logicalReachesThroughRelation(c, depth + 1));
|
|
463
|
+
}
|
|
464
|
+
return typeof condition.column === "string" && condition.column.includes(".");
|
|
465
|
+
}
|
|
466
|
+
|
|
441
467
|
/**
|
|
442
468
|
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
443
469
|
*
|
|
@@ -462,6 +488,12 @@ export function isLocallySortable(
|
|
|
462
488
|
): boolean {
|
|
463
489
|
const keys = normalizeOrderBy(orderBy);
|
|
464
490
|
if (!keys) return true;
|
|
491
|
+
// An aggregate over a relation is computed by the database and is not a
|
|
492
|
+
// field on the row, so every cached row reads `undefined` for it. That
|
|
493
|
+
// looks exactly like a column of nulls to the loop below, which would call
|
|
494
|
+
// the order reproducible and then hand back rows in id order — a queue
|
|
495
|
+
// sorted by nothing at all, presented as the server's answer.
|
|
496
|
+
if (keys.some(([field]) => parseRelationAggregateSort(field))) return false;
|
|
465
497
|
// Every key has to be decidable, not just the first: a sort the local side
|
|
466
498
|
// can only agree with down to its second column is one it disagrees with.
|
|
467
499
|
return keys.every(([field]) => rows.every((row) => {
|
|
@@ -30,8 +30,8 @@ describe("transport baseUrl resolution", () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it("follows the page, so a second hostname on the same app just works", () => {
|
|
33
|
-
setWindow("https://dadaki.
|
|
34
|
-
expect(createTransport({}).baseUrl).toBe("https://dadaki.
|
|
33
|
+
setWindow("https://dadaki.rebase.website");
|
|
34
|
+
expect(createTransport({}).baseUrl).toBe("https://dadaki.rebase.website");
|
|
35
35
|
setWindow("https://dadaki.com");
|
|
36
36
|
expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
|
|
37
37
|
});
|