@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +331 -79
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +84 -0
- package/dist/sdk_query_builder.d.ts +45 -3
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +198 -55
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +40 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +114 -1
- package/src/offline.ts +123 -8
- package/src/query-contract.types.ts +114 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +55 -3
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +34 -0
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +18 -9
package/src/offline-query.ts
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
package/src/offline-store.ts
CHANGED
|
@@ -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
|
package/src/offline.test.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
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 ??
|
|
1158
|
-
offset: meta.offset ??
|
|
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
|
-
|
|
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>);
|