@rebasepro/client 0.13.0 → 0.13.1-canary.g249daa1

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.
@@ -9,6 +9,7 @@ import {
9
9
  toCanonicalOp
10
10
  } from "@rebasepro/types";
11
11
  import { FindParams } from "./transport";
12
+ import { resolveFindWindow } from "@rebasepro/common";
12
13
 
13
14
  /**
14
15
  * A local evaluator for `FindParams`, so cached rows can answer a query the
@@ -38,8 +39,15 @@ const collator = typeof Intl !== "undefined" && typeof Intl.Collator === "functi
38
39
  ? new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" })
39
40
  : undefined;
40
41
 
41
- /** The server's page size when the caller does not ask for one. */
42
- export const DEFAULT_PAGE_SIZE = 20;
42
+ /**
43
+ * The server's page size when the caller does not ask for one.
44
+ *
45
+ * Re-exported rather than redeclared. This was its own `= 20` — a third
46
+ * constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
47
+ * the 50 the REST layer actually applies — and a local copy of a number that
48
+ * belongs to another process is a number that goes stale silently.
49
+ */
50
+ export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
43
51
 
44
52
  function isNullish(value: unknown): boolean {
45
53
  return value === null || value === undefined;
@@ -137,17 +145,31 @@ export function looseEquals(a: unknown, b: unknown): boolean {
137
145
  */
138
146
  function likeToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
139
147
  let source = "^";
148
+ // Runs of `%` collapse to one. `%%%%X` means exactly what `%X` means, but
149
+ // as a regular expression it is four adjacent unbounded quantifiers, and on
150
+ // a subject that does not match the engine tries every way of splitting the
151
+ // subject between them. Fourteen of them against a forty-eight character
152
+ // value took eighty-seven seconds to answer `false`.
153
+ //
154
+ // The pattern is user input — `?title=like.%25%25%25…` over HTTP — so that
155
+ // is a request that pins a CPU. Collapsing is semantics-preserving and
156
+ // removes the ambiguity the backtracking feeds on.
157
+ let lastWasWildcard = false;
140
158
  for (let i = 0; i < pattern.length; i++) {
141
159
  const char = pattern[i];
142
160
  if (char === "\\" && i + 1 < pattern.length) {
143
161
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144
162
  i++;
163
+ lastWasWildcard = false;
145
164
  } else if (char === "%") {
146
- source += "[\\s\\S]*";
165
+ if (!lastWasWildcard) source += "[\\s\\S]*";
166
+ lastWasWildcard = true;
147
167
  } else if (char === "_") {
148
168
  source += "[\\s\\S]";
169
+ lastWasWildcard = false;
149
170
  } else {
150
171
  source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
172
+ lastWasWildcard = false;
151
173
  }
152
174
  }
153
175
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
@@ -311,12 +333,17 @@ function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): numbe
311
333
  return cmp ?? 0;
312
334
  }
313
335
 
314
- /** Resolve `page`/`offset`/`limit` the way the server does. */
336
+ /**
337
+ * Resolve `page`/`offset`/`limit` the way the server does.
338
+ *
339
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
340
+ * 50, so the same `observe()` answered with 20 rows from the local database and
341
+ * 50 from the network — a list that changed length depending on which side
342
+ * answered, with `page` striding differently on each. Delegated now, so the
343
+ * sentence above is true by construction rather than by agreement.
344
+ */
315
345
  export function resolvePagination(params?: FindParams): { limit: number; offset: number } {
316
- const limit = params?.limit ?? DEFAULT_PAGE_SIZE;
317
- const offset = params?.page != null
318
- ? Math.max(0, (params.page - 1) * limit)
319
- : (params?.offset ?? 0);
346
+ const { limit, offset } = resolveFindWindow(params);
320
347
  return { limit, offset };
321
348
  }
322
349
 
@@ -332,6 +359,11 @@ export function isExactlyEvaluable(params?: FindParams): boolean {
332
359
  if (!params) return true;
333
360
  if (params.include && params.include.length > 0) return false;
334
361
  if (params.searchString) return false;
362
+ // Nearest-neighbour ordering is the server's to compute: the cache holds no
363
+ // vectors, and even with them, answering from a subset would return the
364
+ // nearest of what happens to be cached while looking like the nearest there
365
+ // are — a wrong answer that is indistinguishable from a right one.
366
+ if (params.vectorSearch) return false;
335
367
  return true;
336
368
  }
337
369
 
@@ -52,9 +52,13 @@ export interface PendingMutation {
52
52
  /** Unique, lexicographically sortable identity — also the queue key suffix. */
53
53
  mutationId: string;
54
54
  collection: string;
55
- type: "create" | "createMany" | "update" | "delete";
55
+ type: "create" | "createMany" | "update" | "updateMany" | "delete" | "deleteMany";
56
56
  /** Target row id for update/delete, and the (client-generated) id of an offline create. */
57
57
  id?: string | number;
58
+ /** Target row ids for `deleteMany`. */
59
+ ids?: (string | number)[];
60
+ /** `{ id, data }` entries for `updateMany`. */
61
+ updates?: { id: string | number; data: Record<string, unknown> }[];
58
62
  /**
59
63
  * True when the SDK minted this create's id itself. Only such creates may
60
64
  * cancel out against a later offline delete: a freshly generated UUID
@@ -16,7 +16,7 @@ type Row = Record<string, unknown>;
16
16
  function createFakeServer() {
17
17
  const state = { online: true };
18
18
  const tables = new Map<string, Map<string, Row>>();
19
- const log: { collection: string; op: string; id?: unknown; upsert?: boolean }[] = [];
19
+ const log: { collection: string; op: string; id?: unknown; ids?: unknown[]; upsert?: boolean }[] = [];
20
20
 
21
21
  function table(slug: string): Map<string, Row> {
22
22
  if (!tables.has(slug)) tables.set(slug, new Map());
@@ -67,11 +67,30 @@ function createFakeServer() {
67
67
  table(slug).set(String(id), row);
68
68
  return row;
69
69
  },
70
+ async updateMany(updates: { id: string | number; data: Partial<Row> }[]) {
71
+ assertOnline();
72
+ log.push({ collection: slug, op: "updateMany", ids: updates.map(u => u.id) });
73
+ return updates.map(({ id, data }) => {
74
+ const existing = table(slug).get(String(id));
75
+ if (!existing) throw new RebaseApiError("Not found", { status: 404 });
76
+ const row = { ...existing, ...data };
77
+ table(slug).set(String(id), row);
78
+ return row;
79
+ });
80
+ },
70
81
  async delete(id: string | number) {
71
82
  assertOnline();
72
83
  log.push({ collection: slug, op: "delete", id });
73
84
  table(slug).delete(String(id));
74
85
  },
86
+ async deleteMany(ids: (string | number)[]) {
87
+ assertOnline();
88
+ log.push({ collection: slug, op: "deleteMany", ids });
89
+ for (const id of ids) {
90
+ if (!table(slug).has(String(id))) throw new RebaseApiError("Not found", { status: 404 });
91
+ }
92
+ for (const id of ids) table(slug).delete(String(id));
93
+ },
75
94
  async count(_params?: FindParams) {
76
95
  assertOnline();
77
96
  return table(slug).size;
@@ -503,6 +522,100 @@ describe("OfflineManager", () => {
503
522
  });
504
523
  });
505
524
 
525
+ describe("updateMany / deleteMany", () => {
526
+ it("queues a bulk update offline, overlays it, and replays it once", async () => {
527
+ const server = createFakeServer();
528
+ const { manager, wrap } = createManager(server);
529
+ const posts = wrap("posts");
530
+ await posts.create({ title: "a" });
531
+ await posts.create({ title: "b" });
532
+ const seeded = (await posts.find()).data;
533
+
534
+ server.state.online = false;
535
+ const updated = await posts.updateMany(
536
+ seeded.map((r) => ({ id: r.id as string, data: { title: `${r.title}!` } }))
537
+ );
538
+ expect(updated.map((r) => r.title)).toEqual(["a!", "b!"]);
539
+
540
+ // The overlay is what a UI renders while the write is queued.
541
+ const overlaid = await posts.find();
542
+ expect(overlaid.data.map((r) => r.title).sort()).toEqual(["a!", "b!"]);
543
+
544
+ server.state.online = true;
545
+ await manager.sync();
546
+ const bulk = server.log.filter((e) => e.op === "updateMany");
547
+ expect(bulk).toHaveLength(1);
548
+ expect([...server.table("posts").values()].map((r) => r.title).sort()).toEqual(["a!", "b!"]);
549
+ });
550
+
551
+ it("queues a bulk delete offline, hides the rows, and replays it once", async () => {
552
+ const server = createFakeServer();
553
+ const { manager, wrap } = createManager(server);
554
+ const posts = wrap("posts");
555
+ await posts.create({ title: "a" });
556
+ await posts.create({ title: "b" });
557
+ await posts.create({ title: "c" });
558
+ const seeded = (await posts.find()).data;
559
+
560
+ server.state.online = false;
561
+ await posts.deleteMany(seeded.slice(0, 2).map((r) => r.id as string));
562
+
563
+ const overlaid = await posts.find();
564
+ expect(overlaid.data.map((r) => r.title)).toEqual(["c"]);
565
+
566
+ server.state.online = true;
567
+ await manager.sync();
568
+ const bulk = server.log.filter((e) => e.op === "deleteMany");
569
+ expect(bulk).toHaveLength(1);
570
+ expect([...server.table("posts").values()].map((r) => r.title)).toEqual(["c"]);
571
+ });
572
+
573
+ it("queues a bulk update whose rows have writes still pending", async () => {
574
+ // Splitting the batch — some rows now, some later — would break the
575
+ // one guarantee a batch makes, and would reorder a write against a
576
+ // row whose own create has not landed yet.
577
+ const server = createFakeServer();
578
+ const { manager, wrap } = createManager(server);
579
+ const posts = wrap("posts");
580
+ await posts.find();
581
+
582
+ server.state.online = false;
583
+ const created = await posts.create({ title: "fresh" });
584
+ await posts.updateMany([{ id: created.id as string, data: { title: "edited" } }]);
585
+
586
+ server.state.online = true;
587
+ await manager.sync();
588
+
589
+ // The create ran first, then the batch — order preserved.
590
+ expect(server.log.map((e) => e.op)).toEqual(["create", "updateMany"]);
591
+ expect([...server.table("posts").values()][0].title).toBe("edited");
592
+ });
593
+
594
+ it("goes straight to the server when online", async () => {
595
+ const server = createFakeServer();
596
+ const { wrap } = createManager(server);
597
+ const posts = wrap("posts");
598
+ const a = await posts.create({ title: "a" });
599
+
600
+ await posts.updateMany([{ id: a.id as string, data: { title: "A" } }]);
601
+ expect(server.table("posts").get(String(a.id))!.title).toBe("A");
602
+
603
+ await posts.deleteMany([a.id as string]);
604
+ expect(server.table("posts").size).toBe(0);
605
+ });
606
+
607
+ it("treats empty input as a no-op that touches neither queue nor server", async () => {
608
+ const server = createFakeServer();
609
+ const { wrap } = createManager(server);
610
+ const posts = wrap("posts");
611
+ await posts.find();
612
+
613
+ expect(await posts.updateMany([])).toEqual([]);
614
+ await posts.deleteMany([]);
615
+ expect(server.log.filter((e) => e.op.endsWith("Many"))).toHaveLength(0);
616
+ });
617
+ });
618
+
506
619
  describe("concurrency", () => {
507
620
  it("gives concurrent offline writes distinct, ordered mutation ids", async () => {
508
621
  const server = createFakeServer();
package/src/offline.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { buildQueryString, FindParams, RebaseApiError } from "./transport";
2
- import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
2
+ import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue, WriteOptions } from "@rebasepro/types";
3
3
  import { collectAllPages, paginateFind } from "@rebasepro/common";
4
4
  import { CollectionClient, LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
5
5
  import { SDKQueryBuilder } from "./sdk_query_builder";
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  isExactlyEvaluable,
17
17
  matchesParams,
18
+ resolvePagination,
18
19
  runLocalQuery,
19
20
  sortRows
20
21
  } from "./offline-query";
@@ -545,6 +546,83 @@ export class OfflineManager {
545
546
  return rows;
546
547
  },
547
548
 
549
+ updateMany: async (updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) => {
550
+ await this.ensureCollection(slug);
551
+ if (!Array.isArray(updates)) {
552
+ throw new TypeError("updateMany expects an array of { id, data } entries.");
553
+ }
554
+ if (updates.length === 0) return [];
555
+ // Any row in the batch with a write already queued sends the
556
+ // whole batch to the queue. Splitting it — some rows now, some
557
+ // later — would break the one guarantee a batch makes, that its
558
+ // rows land together, and would reorder writes against a row
559
+ // whose own create has not landed yet.
560
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
561
+ if (this.connectivity.shouldAttempt() && !anyPending) {
562
+ try {
563
+ const rows = await inner.updateMany(updates, options);
564
+ this.connectivity.markSuccess();
565
+ await this.ingest(slug, rows);
566
+ this.notifyCollection(slug);
567
+ return rows;
568
+ } catch (error) {
569
+ if (!isNetworkError(error)) throw error;
570
+ this.connectivity.markFailure();
571
+ }
572
+ }
573
+ const rollback: Record<string, AnyRow | null> = {};
574
+ const optimistic: M[] = [];
575
+ for (const { id, data } of updates) {
576
+ const base = this.rawLocalRow(slug, id);
577
+ rollback[String(id)] = base ?? null;
578
+ optimistic.push({ ...(base ?? {}), ...(data as AnyRow), id } as unknown as M);
579
+ }
580
+ await this.enqueue({
581
+ collection: slug,
582
+ type: "updateMany",
583
+ updates: updates.map((u) => ({ id: u.id,
584
+ data: u.data as AnyRow })),
585
+ rollback: { rows: rollback }
586
+ });
587
+ for (const row of optimistic) this.setLocalRow(slug, row.id as string | number, row);
588
+ this.notifyCollection(slug);
589
+ return optimistic;
590
+ },
591
+
592
+ deleteMany: async (ids: (string | number)[], options?: WriteOptions) => {
593
+ await this.ensureCollection(slug);
594
+ if (!Array.isArray(ids)) {
595
+ throw new TypeError("deleteMany expects an array of ids.");
596
+ }
597
+ if (ids.length === 0) return;
598
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
599
+ if (this.connectivity.shouldAttempt() && !anyPending) {
600
+ try {
601
+ await inner.deleteMany(ids, options);
602
+ this.connectivity.markSuccess();
603
+ for (const id of ids) this.removeLocalRow(slug, id, true);
604
+ this.notifyCollection(slug);
605
+ this.scheduleRefresh(slug);
606
+ return;
607
+ } catch (error) {
608
+ if (!isNetworkError(error)) throw error;
609
+ this.connectivity.markFailure();
610
+ }
611
+ }
612
+ const rollback: Record<string, AnyRow | null> = {};
613
+ for (const id of ids) {
614
+ rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
615
+ }
616
+ await this.enqueue({
617
+ collection: slug,
618
+ type: "deleteMany",
619
+ ids,
620
+ rollback: { rows: rollback }
621
+ });
622
+ for (const id of ids) this.removeLocalRow(slug, id, false);
623
+ this.notifyCollection(slug);
624
+ },
625
+
548
626
  update: async (id: string | number, data: Partial<M>) => {
549
627
  await this.ensureCollection(slug);
550
628
  // Never overtake a write already queued for this row. The
@@ -657,7 +735,8 @@ export class OfflineManager {
657
735
  orderBy: (column, direction) => new SDKQueryBuilder<M>(wrapped).orderBy(column, direction),
658
736
  limit: (count) => new SDKQueryBuilder<M>(wrapped).limit(count),
659
737
  offset: (count) => new SDKQueryBuilder<M>(wrapped).offset(count),
660
- search: (searchString) => new SDKQueryBuilder<M>(wrapped).search(searchString),
738
+ search: (searchString, options) => new SDKQueryBuilder<M>(wrapped).search(searchString, options),
739
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder<M>(wrapped).vectorSearch(property, vector, options),
661
740
  include: (...relations) => new SDKQueryBuilder<M>(wrapped).include(...relations)
662
741
  };
663
742
 
@@ -942,7 +1021,7 @@ export class OfflineManager {
942
1021
  if (!state) {
943
1022
  return {
944
1023
  data: [],
945
- meta: { total: 0, limit: params?.limit ?? 20, offset: params?.offset ?? 0, hasMore: false },
1024
+ meta: { ...resolvePagination(params), total: 0, hasMore: false },
946
1025
  fromCache: true,
947
1026
  hasPendingWrites: false,
948
1027
  partial: true
@@ -996,9 +1075,18 @@ export class OfflineManager {
996
1075
  rows.push(entry.row as M);
997
1076
  added++;
998
1077
  }
999
- if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
1000
1078
  }
1001
1079
 
1080
+ // Order is part of the query, not a detail of how the rows were
1081
+ // obtained. This used to sort only when a locally-created row had been
1082
+ // injected — every other read handed back cache order, which is
1083
+ // insertion order, and a caller that asked for `orderBy` got whatever
1084
+ // the store happened to hold. In the admin that is the collection's
1085
+ // `sort` being silently ignored on every list backed by this overlay:
1086
+ // the query carries it, the server honours it, and the answer served
1087
+ // from here did not.
1088
+ if (params?.orderBy) sortRows(rows, params.orderBy);
1089
+
1002
1090
  const total = Math.max(rows.length, snapshot.total - removed + added);
1003
1091
  return {
1004
1092
  data: rows,
@@ -1150,12 +1238,13 @@ export class OfflineManager {
1150
1238
  }
1151
1239
 
1152
1240
  private recordSnapshot(slug: string, params: FindParams | undefined, result: FindResult<AnyRow>): QuerySnapshot {
1153
- const meta = result.meta ?? { total: result.data?.length ?? 0, limit: 20, offset: 0, hasMore: false };
1241
+ const window = resolvePagination(params);
1242
+ const meta = result.meta ?? { total: result.data?.length ?? 0, ...window, hasMore: false };
1154
1243
  const snapshot: QuerySnapshot = {
1155
1244
  ids: (result.data ?? []).map((row) => row.id as string | number).filter((id) => id !== undefined),
1156
1245
  total: meta.total ?? result.data?.length ?? 0,
1157
- limit: meta.limit ?? params?.limit ?? 20,
1158
- offset: meta.offset ?? params?.offset ?? 0,
1246
+ limit: meta.limit ?? window.limit,
1247
+ offset: meta.offset ?? window.offset,
1159
1248
  hasMore: meta.hasMore ?? false
1160
1249
  };
1161
1250
  const state = this.collectionState(slug);
@@ -1466,13 +1555,39 @@ export class OfflineManager {
1466
1555
  await this.adoptServerRow(op, op.id, row);
1467
1556
  } else if (op.type === "createMany") {
1468
1557
  const queued = (op.data as AnyRow[]) ?? [];
1469
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : undefined);
1558
+ // The mutation id names this batch, exactly as it names a single
1559
+ // `create` above — and it matters more here. Without it, a batch
1560
+ // whose ACK went missing replays as a second genuine import and
1561
+ // duplicates every row it holds, not one. `upsert` masked that for
1562
+ // the callers who set it; nothing covered the ones who did not.
1563
+ const rows = await inner.createMany(queued, {
1564
+ ...(op.upsert ? { upsert: true } : {}),
1565
+ idempotencyKey: op.mutationId
1566
+ });
1470
1567
  for (let i = 0; i < rows.length; i++) {
1471
1568
  await this.adoptServerRow(op, queued[i]?.id as string | number | undefined, rows[i]);
1472
1569
  }
1570
+ } else if (op.type === "updateMany") {
1571
+ const queued = op.updates ?? [];
1572
+ // Keyed like every other replay: an update re-applied in full is
1573
+ // naturally idempotent, but one interleaved with another writer's is
1574
+ // not, and a lost ACK would otherwise re-apply a stale batch over
1575
+ // newer data.
1576
+ const rows = await inner.updateMany(
1577
+ queued.map(u => ({ id: u.id,
1578
+ data: u.data as AnyRow })),
1579
+ { idempotencyKey: op.mutationId }
1580
+ );
1581
+ for (let i = 0; i < rows.length; i++) {
1582
+ await this.ingestReplaced(op, queued[i].id, rows[i]);
1583
+ }
1473
1584
  } else if (op.type === "update") {
1474
1585
  const row = await inner.update(op.id!, op.data as AnyRow);
1475
1586
  await this.ingestReplaced(op, op.id!, row);
1587
+ } else if (op.type === "deleteMany") {
1588
+ const ids = op.ids ?? [];
1589
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
1590
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
1476
1591
  } else if (op.type === "delete") {
1477
1592
  await inner.delete(op.id!);
1478
1593
  this.removeLocalRow(op.collection, op.id!, true);
@@ -0,0 +1,114 @@
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 } from "@rebasepro/types";
27
+
28
+ /**
29
+ * A row shaped the way a **generated** SDK shapes one: a type alias with a
30
+ * finite key set.
31
+ *
32
+ * This detail is the whole test. An `interface … extends Record<string,
33
+ * unknown>` also satisfies the constraint, but its index signature makes
34
+ * `keyof M` collapse to `string` — so every assertion below would pass no
35
+ * matter what `orderBy` accepted, typos included. That is how the first draft
36
+ * of this file was written, and every `@ts-expect-error` in it reported
37
+ * "unused directive": the fixture proved nothing.
38
+ *
39
+ * A generated row type has no index signature, which is exactly why a real
40
+ * project caught what this repo did not.
41
+ */
42
+ type ContractRow = {
43
+ id: string;
44
+ title: string;
45
+ created_at: string;
46
+ };
47
+
48
+ // ── orderBy accepts relevance, and still rejects nonsense ───────────────────
49
+
50
+ /** The documented relevance sort must compile. */
51
+ export const orderByScore: FindParams<ContractRow> = {
52
+ searchString: "auditor",
53
+ orderBy: ["_score", "desc"]
54
+ };
55
+
56
+ /** An ordinary column must keep compiling. */
57
+ export const orderByColumn: FindParams<ContractRow> = { orderBy: ["created_at", "desc"] };
58
+
59
+ /**
60
+ * A column that does not exist must still be refused. Widening `orderBy` to
61
+ * `string` would have fixed the `_score` error and silently given up this,
62
+ * turning every typo into an unsorted 200 in production.
63
+ */
64
+ // @ts-expect-error - "nope" is neither a column of ContractRow nor computed
65
+ export const orderByTypo: FindParams<ContractRow> = { orderBy: ["nope", "desc"] };
66
+
67
+ // ── the fluent builder agrees with FindParams ──────────────────────────────
68
+
69
+ export const fluentScore = (qb: SDKQueryBuilderInterface<ContractRow>) =>
70
+ qb.search("auditor").orderBy("_score", "desc");
71
+
72
+ export const fluentColumn = (qb: SDKQueryBuilderInterface<ContractRow>) =>
73
+ qb.orderBy("created_at", "asc");
74
+
75
+ export const fluentTypo = (qb: SDKQueryBuilderInterface<ContractRow>) =>
76
+ // @ts-expect-error - the fluent signature must reject what FindParams rejects
77
+ qb.orderBy("_scoer", "desc");
78
+
79
+ /** Vector search must be reachable from the builder, and chain. */
80
+ export const fluentVector = (qb: SDKQueryBuilderInterface<ContractRow>) =>
81
+ qb.vectorSearch("embedding", [0.1, 0.2], { threshold: 0.3 }).limit(10);
82
+
83
+ // ── what a query computes is readable off the row ──────────────────────────
84
+
85
+ /**
86
+ * Sorting by relevance and then being unable to read it was the other half of
87
+ * the same bug — the e2e cast around it, which should have been the tell.
88
+ */
89
+ export const readComputed = (result: FindResult<ContractRow>) => {
90
+ const row = result.data[0];
91
+ const score: number | undefined = row._score;
92
+ const distance: number | undefined = row._distance;
93
+ const title: string = row.title;
94
+ return { score, distance, title };
95
+ };
96
+
97
+ /** Widening the row must not have turned it into `any`. */
98
+ export const readUnknown = (result: FindResult<ContractRow>) =>
99
+ // @ts-expect-error - `nope` is neither a column nor computed
100
+ result.data[0].nope;
101
+
102
+ /**
103
+ * A result row must stay assignable to `Record<string, unknown>`.
104
+ *
105
+ * Widening the row to `M & QueryComputedFields` broke this in seven places in
106
+ * one downstream app, because `QueryComputedFields` was first written as an
107
+ * `interface`: TypeScript grants an implicit index signature to a type alias
108
+ * and withholds it from an interface, so the intersection stopped overlapping
109
+ * with `Record<string, unknown>` and every `as Record<string, unknown>` cast
110
+ * became an error. Nothing in this repo casts a row that way, which is why
111
+ * nothing here noticed.
112
+ */
113
+ export const rowStaysIndexable = (result: FindResult<ContractRow>) =>
114
+ result.data.map(row => row as Record<string, unknown>);