@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.14.1",
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.1",
33
- "@rebasepro/utils": "0.14.1",
34
- "@rebasepro/types": "0.14.1"
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", () => {
@@ -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.apps.rebase.pro");
34
- expect(createTransport({}).baseUrl).toBe("https://dadaki.apps.rebase.pro");
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
  });