@rebasepro/client 0.10.0 → 0.10.1-canary.14e53ae
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/README.md +9 -1
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +2045 -10
- package/dist/index.es.js.map +1 -1
- package/dist/offline-codec.d.ts +4 -0
- package/dist/offline-connectivity.d.ts +78 -0
- package/dist/offline-query.d.ts +51 -0
- package/dist/offline-store.d.ts +150 -0
- package/dist/offline.d.ts +306 -0
- package/dist/transport.d.ts +16 -0
- package/package.json +5 -4
- package/src/auth.ts +71 -3
- package/src/collection.ts +118 -2
- package/src/index.ts +58 -1
- package/src/offline-codec.ts +79 -0
- package/src/offline-connectivity.test.ts +157 -0
- package/src/offline-connectivity.ts +207 -0
- package/src/offline-idb-store.test.ts +286 -0
- package/src/offline-integration.test.ts +175 -0
- package/src/offline-query.test.ts +249 -0
- package/src/offline-query.ts +356 -0
- package/src/offline-store.ts +353 -0
- package/src/offline-sync-engine.test.ts +727 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1687 -0
- package/src/storage.ts +11 -3
- package/src/transport.ts +17 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compareValues,
|
|
3
|
+
isExactlyEvaluable,
|
|
4
|
+
looseEquals,
|
|
5
|
+
matchesLogical,
|
|
6
|
+
matchesOperator,
|
|
7
|
+
matchesParams,
|
|
8
|
+
matchesWhere,
|
|
9
|
+
resolvePagination,
|
|
10
|
+
runLocalQuery,
|
|
11
|
+
sortRows
|
|
12
|
+
} from "./offline-query";
|
|
13
|
+
import { EntityRelation } from "@rebasepro/types";
|
|
14
|
+
import type { FindParams } from "./transport";
|
|
15
|
+
|
|
16
|
+
type Row = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The local evaluator is what lets a row written offline appear in the
|
|
20
|
+
* filtered lists it belongs to, so every divergence from the Postgres driver's
|
|
21
|
+
* semantics shows up as a list that is subtly wrong only when the network is.
|
|
22
|
+
* These pin the cases where SQL and JavaScript disagree.
|
|
23
|
+
*/
|
|
24
|
+
describe("local query engine", () => {
|
|
25
|
+
describe("operators", () => {
|
|
26
|
+
it("compares numbers written as strings by the wire as numbers", () => {
|
|
27
|
+
// The wire format carries no types, so `["<", "10"]` arrives as a
|
|
28
|
+
// string. Comparing it as text would order "10" before "9".
|
|
29
|
+
expect(matchesOperator(9, "<", "10")).toBe(true);
|
|
30
|
+
expect(matchesOperator("9", "<", 10)).toBe(true);
|
|
31
|
+
expect(matchesOperator(10, "<", "9")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats a comparison against NULL as unknown, not as false-or-true", () => {
|
|
35
|
+
// `WHERE status != 'done'` does not return rows with a NULL status.
|
|
36
|
+
expect(matchesOperator(null, "!=", "done")).toBe(false);
|
|
37
|
+
expect(matchesOperator(undefined, "!=", "done")).toBe(false);
|
|
38
|
+
expect(matchesOperator(null, "<", 5)).toBe(false);
|
|
39
|
+
expect(matchesOperator(null, ">", 5)).toBe(false);
|
|
40
|
+
expect(matchesOperator(null, "in", ["a"])).toBe(false);
|
|
41
|
+
expect(matchesOperator(null, "not-in", ["a"])).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("tests null with the operators meant for it", () => {
|
|
45
|
+
expect(matchesOperator(null, "is-null", null)).toBe(true);
|
|
46
|
+
expect(matchesOperator(undefined, "is-null", null)).toBe(true);
|
|
47
|
+
expect(matchesOperator(0, "is-null", null)).toBe(false);
|
|
48
|
+
expect(matchesOperator("", "is-not-null", null)).toBe(true);
|
|
49
|
+
expect(matchesOperator(null, "is-not-null", null)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("matches dates against instants and ISO strings alike", () => {
|
|
53
|
+
const row = new Date("2026-07-01T00:00:00Z");
|
|
54
|
+
expect(matchesOperator(row, ">=", new Date("2026-06-01T00:00:00Z"))).toBe(true);
|
|
55
|
+
expect(matchesOperator(row, "<", "2026-08-01T00:00:00Z")).toBe(true);
|
|
56
|
+
expect(matchesOperator(row, "==", new Date("2026-07-01T00:00:00Z"))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("compares a relation by the id it points at", () => {
|
|
60
|
+
const relation = new EntityRelation("u1", "users");
|
|
61
|
+
expect(matchesOperator(relation, "==", "u1")).toBe(true);
|
|
62
|
+
expect(matchesOperator(relation, "in", ["u1", "u2"])).toBe(true);
|
|
63
|
+
expect(matchesOperator(relation, "==", "u2")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles list membership and array columns", () => {
|
|
67
|
+
expect(matchesOperator("editor", "in", ["admin", "editor"])).toBe(true);
|
|
68
|
+
expect(matchesOperator("viewer", "not-in", ["admin", "editor"])).toBe(true);
|
|
69
|
+
expect(matchesOperator(["a", "b"], "array-contains", "b")).toBe(true);
|
|
70
|
+
expect(matchesOperator(["a", "b"], "array-contains", "c")).toBe(false);
|
|
71
|
+
expect(matchesOperator(["a", "b"], "array-contains-any", ["c", "b"])).toBe(true);
|
|
72
|
+
expect(matchesOperator("not-an-array", "array-contains", "a")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reads LIKE patterns as SQL does, wildcards and escapes included", () => {
|
|
76
|
+
expect(matchesOperator("post-1", "like", "post-%")).toBe(true);
|
|
77
|
+
expect(matchesOperator("Post-1", "like", "post-%")).toBe(false);
|
|
78
|
+
expect(matchesOperator("Post-1", "ilike", "post-%")).toBe(true);
|
|
79
|
+
expect(matchesOperator("abc", "like", "a_c")).toBe(true);
|
|
80
|
+
expect(matchesOperator("abbc", "like", "a_c")).toBe(false);
|
|
81
|
+
expect(matchesOperator("50%", "like", "50\\%")).toBe(true);
|
|
82
|
+
expect(matchesOperator("500", "like", "50\\%")).toBe(false);
|
|
83
|
+
expect(matchesOperator("a.c", "like", "a.c")).toBe(true);
|
|
84
|
+
// A regex metacharacter in the pattern is a literal, not a wildcard.
|
|
85
|
+
expect(matchesOperator("abc", "like", "a.c")).toBe(false);
|
|
86
|
+
expect(matchesOperator("Post-1", "not-ilike", "post-%")).toBe(false);
|
|
87
|
+
expect(matchesOperator("other", "not-like", "post-%")).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts REST short-codes wherever canonical operators are accepted", () => {
|
|
91
|
+
// Filters that came off the wire carry `eq`/`gte`, not `==`/`>=`.
|
|
92
|
+
expect(matchesWhere({ status: "active" }, { status: ["eq" as "==", "active"] })).toBe(true);
|
|
93
|
+
expect(matchesWhere({ age: 20 }, { age: ["gte" as ">=", 18] })).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps an unknown operator from silently dropping rows", () => {
|
|
97
|
+
// Better a list that is too broad than one that quietly hides rows
|
|
98
|
+
// because this build predates an operator.
|
|
99
|
+
expect(matchesOperator("x", "spaceship" as never, "y")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("where clauses", () => {
|
|
104
|
+
const row: Row = { status: "active", age: 30, tags: ["a"] };
|
|
105
|
+
|
|
106
|
+
it("ANDs across fields and across tuples on one field", () => {
|
|
107
|
+
expect(matchesWhere(row, { status: ["==", "active"], age: [">=", 18] })).toBe(true);
|
|
108
|
+
expect(matchesWhere(row, { status: ["==", "active"], age: [">=", 40] })).toBe(false);
|
|
109
|
+
expect(matchesWhere(row, { age: [[">=", 18], ["<", 65]] })).toBe(true);
|
|
110
|
+
expect(matchesWhere(row, { age: [[">=", 18], ["<", 25]] })).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("is vacuously true with no clause", () => {
|
|
114
|
+
expect(matchesWhere(row, undefined)).toBe(true);
|
|
115
|
+
expect(matchesWhere(row, {})).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("logical trees", () => {
|
|
120
|
+
const row: Row = { status: "draft", author: "me" };
|
|
121
|
+
|
|
122
|
+
it("evaluates nested and/or", () => {
|
|
123
|
+
expect(matchesLogical(row, {
|
|
124
|
+
type: "or",
|
|
125
|
+
conditions: [
|
|
126
|
+
{ column: "status", operator: "==", value: "published" },
|
|
127
|
+
{ column: "author", operator: "==", value: "me" }
|
|
128
|
+
]
|
|
129
|
+
})).toBe(true);
|
|
130
|
+
|
|
131
|
+
expect(matchesLogical(row, {
|
|
132
|
+
type: "and",
|
|
133
|
+
conditions: [
|
|
134
|
+
{ column: "status", operator: "==", value: "draft" },
|
|
135
|
+
{
|
|
136
|
+
type: "or",
|
|
137
|
+
conditions: [
|
|
138
|
+
{ column: "author", operator: "==", value: "you" },
|
|
139
|
+
{ column: "author", operator: "==", value: "me" }
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
})).toBe(true);
|
|
144
|
+
|
|
145
|
+
expect(matchesLogical(row, {
|
|
146
|
+
type: "or",
|
|
147
|
+
conditions: [{ column: "status", operator: "==", value: "published" }]
|
|
148
|
+
})).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("ordering", () => {
|
|
153
|
+
it("puts nulls last ascending and first descending, as Postgres does", () => {
|
|
154
|
+
const rows: Row[] = [{ id: 1, n: 2 }, { id: 2, n: null }, { id: 3, n: 1 }];
|
|
155
|
+
expect(sortRows([...rows], ["n", "asc"]).map((r) => r.id)).toEqual([3, 1, 2]);
|
|
156
|
+
expect(sortRows([...rows], ["n", "desc"]).map((r) => r.id)).toEqual([2, 1, 3]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("breaks ties on id so paging cannot shuffle equal rows between pages", () => {
|
|
160
|
+
const rows: Row[] = [{ id: "c", n: 1 }, { id: "a", n: 1 }, { id: "b", n: 1 }];
|
|
161
|
+
expect(sortRows(rows, ["n", "asc"]).map((r) => r.id)).toEqual(["a", "b", "c"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("orders dates chronologically, not lexicographically", () => {
|
|
165
|
+
const rows: Row[] = [
|
|
166
|
+
{ id: 1, at: new Date("2026-01-02T00:00:00Z") },
|
|
167
|
+
{ id: 2, at: new Date("2025-12-31T00:00:00Z") }
|
|
168
|
+
];
|
|
169
|
+
expect(sortRows(rows, ["at", "asc"]).map((r) => r.id)).toEqual([2, 1]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("pagination", () => {
|
|
174
|
+
it("resolves page over offset the way the server does", () => {
|
|
175
|
+
expect(resolvePagination()).toEqual({ limit: 20, offset: 0 });
|
|
176
|
+
expect(resolvePagination({ limit: 10, offset: 30 })).toEqual({ limit: 10, offset: 30 });
|
|
177
|
+
expect(resolvePagination({ limit: 10, page: 3 })).toEqual({ limit: 10, offset: 20 });
|
|
178
|
+
// `page` wins over `offset`, and the first page is not negative.
|
|
179
|
+
expect(resolvePagination({ limit: 10, page: 1, offset: 99 })).toEqual({ limit: 10, offset: 0 });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("runLocalQuery", () => {
|
|
184
|
+
const rows: Row[] = [
|
|
185
|
+
{ id: "a", n: 3, status: "draft" },
|
|
186
|
+
{ id: "b", n: 1, status: "draft" },
|
|
187
|
+
{ id: "c", n: 2, status: "published" }
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
it("filters, sorts and paginates, reporting the unpaginated total", () => {
|
|
191
|
+
const result = runLocalQuery(rows, {
|
|
192
|
+
where: { status: ["==", "draft"] },
|
|
193
|
+
orderBy: ["n", "asc"],
|
|
194
|
+
limit: 1
|
|
195
|
+
});
|
|
196
|
+
expect(result.data.map((r) => r.id)).toEqual(["b"]);
|
|
197
|
+
expect(result.meta).toEqual({ total: 2, limit: 1, offset: 0, hasMore: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("reports no more rows on the last page", () => {
|
|
201
|
+
const result = runLocalQuery(rows, { orderBy: ["n", "asc"], limit: 2, offset: 2 });
|
|
202
|
+
expect(result.data.map((r) => r.id)).toEqual(["a"]);
|
|
203
|
+
expect(result.meta).toMatchObject({ total: 3, offset: 2, hasMore: false });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("approximates search as a substring scan over string and number fields", () => {
|
|
207
|
+
expect(runLocalQuery(rows, { searchString: "publi" }).data.map((r) => r.id)).toEqual(["c"]);
|
|
208
|
+
expect(runLocalQuery(rows, { searchString: "PUBLI" }).data.map((r) => r.id)).toEqual(["c"]);
|
|
209
|
+
expect(runLocalQuery(rows, { searchString: " " }).data).toHaveLength(3);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("combines where, logical and search with AND", () => {
|
|
213
|
+
const params: FindParams = {
|
|
214
|
+
where: { status: ["==", "draft"] },
|
|
215
|
+
logical: { type: "or", conditions: [{ column: "n", operator: "==", value: 3 }] }
|
|
216
|
+
};
|
|
217
|
+
expect(runLocalQuery(rows, params).data.map((r) => r.id)).toEqual(["a"]);
|
|
218
|
+
expect(matchesParams({ id: "b", n: 1, status: "draft" }, params)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("isExactlyEvaluable", () => {
|
|
223
|
+
it("admits what it cannot answer as well as the server would", () => {
|
|
224
|
+
expect(isExactlyEvaluable(undefined)).toBe(true);
|
|
225
|
+
expect(isExactlyEvaluable({ where: { a: ["==", 1] }, orderBy: ["a", "asc"] })).toBe(true);
|
|
226
|
+
// Real full-text search is not a substring scan…
|
|
227
|
+
expect(isExactlyEvaluable({ searchString: "x" })).toBe(false);
|
|
228
|
+
// …and related rows live in collections this query never loaded.
|
|
229
|
+
expect(isExactlyEvaluable({ include: ["author"] })).toBe(false);
|
|
230
|
+
expect(isExactlyEvaluable({ include: [] })).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("primitives", () => {
|
|
235
|
+
it("compareValues returns undefined rather than an order across NULL", () => {
|
|
236
|
+
expect(compareValues(null, 1)).toBeUndefined();
|
|
237
|
+
expect(compareValues(1, undefined)).toBeUndefined();
|
|
238
|
+
expect(compareValues(1, 2)).toBeLessThan(0);
|
|
239
|
+
expect(compareValues("b", "a")).toBeGreaterThan(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("looseEquals treats null and undefined as the same absence", () => {
|
|
243
|
+
expect(looseEquals(null, undefined)).toBe(true);
|
|
244
|
+
expect(looseEquals(null, 0)).toBe(false);
|
|
245
|
+
expect(looseEquals(true, "true")).toBe(true);
|
|
246
|
+
expect(looseEquals(1, "1")).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EntityRelation,
|
|
3
|
+
FilterValues,
|
|
4
|
+
FindResult,
|
|
5
|
+
LogicalCondition,
|
|
6
|
+
FilterCondition,
|
|
7
|
+
OrderByTuple,
|
|
8
|
+
WhereFilterOp,
|
|
9
|
+
toCanonicalOp
|
|
10
|
+
} from "@rebasepro/types";
|
|
11
|
+
import { FindParams } from "./transport";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A local evaluator for `FindParams`, so cached rows can answer a query the
|
|
15
|
+
* client has never sent to the server — and so a row written offline shows up
|
|
16
|
+
* in every filtered list it belongs to, not just in unfiltered ones.
|
|
17
|
+
*
|
|
18
|
+
* This mirrors the Postgres driver's semantics rather than JavaScript's:
|
|
19
|
+
*
|
|
20
|
+
* - Comparing against NULL is *unknown*, not false-or-true. `status != "done"`
|
|
21
|
+
* excludes rows where `status` is null, exactly as SQL does — a JS `!==`
|
|
22
|
+
* would have included them.
|
|
23
|
+
* - `ORDER BY` puts nulls last ascending and first descending, which is the
|
|
24
|
+
* Postgres default.
|
|
25
|
+
* - The wire format carries no types, so values arriving as strings are
|
|
26
|
+
* compared numerically against numeric columns and as instants against
|
|
27
|
+
* date columns. `["==", "3"]` matches the number `3`, as it does server-side.
|
|
28
|
+
*
|
|
29
|
+
* Two things it deliberately approximates, both flagged by
|
|
30
|
+
* {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
|
|
31
|
+
* substring scan over the row's string fields (the server runs real full-text
|
|
32
|
+
* search over the collection's configured columns), and `include` cannot be
|
|
33
|
+
* evaluated at all, because the related rows live in collections this query
|
|
34
|
+
* knows nothing about.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const collator = typeof Intl !== "undefined" && typeof Intl.Collator === "function"
|
|
38
|
+
? new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" })
|
|
39
|
+
: undefined;
|
|
40
|
+
|
|
41
|
+
/** The server's page size when the caller does not ask for one. */
|
|
42
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
|
43
|
+
|
|
44
|
+
function isNullish(value: unknown): boolean {
|
|
45
|
+
return value === null || value === undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reduce a value to something comparable. Relations compare by the id they
|
|
50
|
+
* point at — the column holds a foreign key, so that is what the server
|
|
51
|
+
* compares too.
|
|
52
|
+
*/
|
|
53
|
+
function toComparable(value: unknown): unknown {
|
|
54
|
+
if (value instanceof Date) return value.getTime();
|
|
55
|
+
if (value instanceof EntityRelation) return value.id;
|
|
56
|
+
if (value && typeof value === "object") {
|
|
57
|
+
const record = value as Record<string, unknown>;
|
|
58
|
+
// A reference/relation that lost its prototype somewhere still
|
|
59
|
+
// compares by id.
|
|
60
|
+
if (typeof record.__type === "string" && "id" in record) return record.id;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Three-way compare with SQL's type coercion but not its collation. Returns
|
|
67
|
+
* `undefined` when the two values are not ordered relative to each other,
|
|
68
|
+
* which is how NULL propagates through a comparison.
|
|
69
|
+
*/
|
|
70
|
+
export function compareValues(a: unknown, b: unknown): number | undefined {
|
|
71
|
+
const left = toComparable(a);
|
|
72
|
+
const right = toComparable(b);
|
|
73
|
+
if (isNullish(left) || isNullish(right)) return undefined;
|
|
74
|
+
|
|
75
|
+
if (typeof left === "boolean" || typeof right === "boolean") {
|
|
76
|
+
const l = left === true || left === "true" || left === 1 ? 1 : 0;
|
|
77
|
+
const r = right === true || right === "true" || right === 1 ? 1 : 0;
|
|
78
|
+
return l - r;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A numeric string on either side means the wire dropped the type; compare
|
|
82
|
+
// as numbers so `["<", "10"]` does not order "10" before "9" as text.
|
|
83
|
+
const leftNum = typeof left === "number" ? left : numericOrNaN(left);
|
|
84
|
+
const rightNum = typeof right === "number" ? right : numericOrNaN(right);
|
|
85
|
+
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
|
|
86
|
+
return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One side is a date-shaped string and the other an instant.
|
|
90
|
+
if (typeof left === "number" || typeof right === "number") {
|
|
91
|
+
const leftTime = toTime(left);
|
|
92
|
+
const rightTime = toTime(right);
|
|
93
|
+
if (leftTime !== undefined && rightTime !== undefined) {
|
|
94
|
+
return leftTime < rightTime ? -1 : leftTime > rightTime ? 1 : 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const leftStr = String(left);
|
|
99
|
+
const rightStr = String(right);
|
|
100
|
+
if (collator) return collator.compare(leftStr, rightStr);
|
|
101
|
+
return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function numericOrNaN(value: unknown): number {
|
|
105
|
+
if (typeof value === "number") return value;
|
|
106
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
107
|
+
const n = Number(value);
|
|
108
|
+
return Number.isNaN(n) ? NaN : n;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "bigint") return Number(value);
|
|
111
|
+
return NaN;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toTime(value: unknown): number | undefined {
|
|
115
|
+
if (typeof value === "number") return value;
|
|
116
|
+
if (typeof value === "string") {
|
|
117
|
+
const t = Date.parse(value);
|
|
118
|
+
return Number.isNaN(t) ? undefined : t;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Equality with the wire's type erasure allowed for, but never across NULL. */
|
|
124
|
+
export function looseEquals(a: unknown, b: unknown): boolean {
|
|
125
|
+
const left = toComparable(a);
|
|
126
|
+
const right = toComparable(b);
|
|
127
|
+
if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
|
|
128
|
+
if (left === right) return true;
|
|
129
|
+
const cmp = compareValues(left, right);
|
|
130
|
+
return cmp === 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Translate a SQL `LIKE` pattern to an anchored regular expression.
|
|
135
|
+
* `%` matches any run of characters, `_` exactly one, and a backslash escapes
|
|
136
|
+
* either of them.
|
|
137
|
+
*/
|
|
138
|
+
function likeToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
|
|
139
|
+
let source = "^";
|
|
140
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
141
|
+
const char = pattern[i];
|
|
142
|
+
if (char === "\\" && i + 1 < pattern.length) {
|
|
143
|
+
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
144
|
+
i++;
|
|
145
|
+
} else if (char === "%") {
|
|
146
|
+
source += "[\\s\\S]*";
|
|
147
|
+
} else if (char === "_") {
|
|
148
|
+
source += "[\\s\\S]";
|
|
149
|
+
} else {
|
|
150
|
+
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function asArray(value: unknown): unknown[] {
|
|
157
|
+
if (Array.isArray(value)) return value;
|
|
158
|
+
if (value === undefined) return [];
|
|
159
|
+
return [value];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Evaluate one canonical operator against one row value. */
|
|
163
|
+
export function matchesOperator(rowValue: unknown, op: WhereFilterOp, filterValue: unknown): boolean {
|
|
164
|
+
switch (op) {
|
|
165
|
+
case "is-null":
|
|
166
|
+
return isNullish(rowValue);
|
|
167
|
+
case "is-not-null":
|
|
168
|
+
return !isNullish(rowValue);
|
|
169
|
+
case "==":
|
|
170
|
+
return looseEquals(rowValue, filterValue);
|
|
171
|
+
case "!=":
|
|
172
|
+
// SQL: `x != v` is unknown when x is NULL, so the row drops out.
|
|
173
|
+
if (isNullish(rowValue)) return false;
|
|
174
|
+
return !looseEquals(rowValue, filterValue);
|
|
175
|
+
case "<":
|
|
176
|
+
case "<=":
|
|
177
|
+
case ">":
|
|
178
|
+
case ">=": {
|
|
179
|
+
const cmp = compareValues(rowValue, filterValue);
|
|
180
|
+
if (cmp === undefined) return false;
|
|
181
|
+
if (op === "<") return cmp < 0;
|
|
182
|
+
if (op === "<=") return cmp <= 0;
|
|
183
|
+
if (op === ">") return cmp > 0;
|
|
184
|
+
return cmp >= 0;
|
|
185
|
+
}
|
|
186
|
+
case "in":
|
|
187
|
+
if (isNullish(rowValue)) return false;
|
|
188
|
+
return asArray(filterValue).some((v) => looseEquals(rowValue, v));
|
|
189
|
+
case "not-in":
|
|
190
|
+
if (isNullish(rowValue)) return false;
|
|
191
|
+
return !asArray(filterValue).some((v) => looseEquals(rowValue, v));
|
|
192
|
+
case "array-contains": {
|
|
193
|
+
if (!Array.isArray(rowValue)) return false;
|
|
194
|
+
return rowValue.some((v) => looseEquals(v, filterValue));
|
|
195
|
+
}
|
|
196
|
+
case "array-contains-any": {
|
|
197
|
+
if (!Array.isArray(rowValue)) return false;
|
|
198
|
+
const wanted = asArray(filterValue);
|
|
199
|
+
return rowValue.some((v) => wanted.some((w) => looseEquals(v, w)));
|
|
200
|
+
}
|
|
201
|
+
case "like":
|
|
202
|
+
case "not-like":
|
|
203
|
+
case "ilike":
|
|
204
|
+
case "not-ilike": {
|
|
205
|
+
if (isNullish(rowValue)) return false;
|
|
206
|
+
const insensitive = op === "ilike" || op === "not-ilike";
|
|
207
|
+
const negated = op === "not-like" || op === "not-ilike";
|
|
208
|
+
const matched = likeToRegExp(String(filterValue), insensitive).test(String(rowValue));
|
|
209
|
+
return negated ? !matched : matched;
|
|
210
|
+
}
|
|
211
|
+
default:
|
|
212
|
+
// An operator this build does not know must not silently drop rows.
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isTuple(value: unknown): value is [WhereFilterOp, unknown] {
|
|
218
|
+
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string"
|
|
219
|
+
&& toCanonicalOp(value[0]) !== undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
|
|
223
|
+
export function matchesWhere(row: Record<string, unknown>, where: FilterValues<string> | undefined): boolean {
|
|
224
|
+
if (!where) return true;
|
|
225
|
+
for (const [field, condition] of Object.entries(where)) {
|
|
226
|
+
if (condition === undefined) continue;
|
|
227
|
+
const tuples: [WhereFilterOp, unknown][] = isTuple(condition)
|
|
228
|
+
? [condition]
|
|
229
|
+
: Array.isArray(condition)
|
|
230
|
+
? (condition as unknown[]).filter(isTuple) as [WhereFilterOp, unknown][]
|
|
231
|
+
: [];
|
|
232
|
+
for (const [rawOp, value] of tuples) {
|
|
233
|
+
const op = toCanonicalOp(rawOp) ?? rawOp;
|
|
234
|
+
if (!matchesOperator(row[field], op, value)) return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Evaluate a nested and/or tree. */
|
|
241
|
+
export function matchesLogical(
|
|
242
|
+
row: Record<string, unknown>,
|
|
243
|
+
condition: LogicalCondition | FilterCondition | undefined
|
|
244
|
+
): boolean {
|
|
245
|
+
if (!condition) return true;
|
|
246
|
+
if ("type" in condition) {
|
|
247
|
+
const children = condition.conditions ?? [];
|
|
248
|
+
if (children.length === 0) return true;
|
|
249
|
+
return condition.type === "or"
|
|
250
|
+
? children.some((c) => matchesLogical(row, c))
|
|
251
|
+
: children.every((c) => matchesLogical(row, c));
|
|
252
|
+
}
|
|
253
|
+
const op = toCanonicalOp(condition.operator) ?? condition.operator;
|
|
254
|
+
return matchesOperator(row[condition.column], op as WhereFilterOp, condition.value);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Approximate the server's full-text search with a case-insensitive substring
|
|
259
|
+
* scan over the row's own string fields. Narrower than the real thing (no
|
|
260
|
+
* stemming, no configured search columns), and it never matches a field the
|
|
261
|
+
* cached row does not carry — a local list may therefore be missing rows the
|
|
262
|
+
* server would have returned, which is why {@link isExactlyEvaluable} refuses
|
|
263
|
+
* to call a search query exact.
|
|
264
|
+
*/
|
|
265
|
+
export function matchesSearch(row: Record<string, unknown>, searchString: string | undefined): boolean {
|
|
266
|
+
if (!searchString) return true;
|
|
267
|
+
const needle = searchString.trim().toLowerCase();
|
|
268
|
+
if (!needle) return true;
|
|
269
|
+
for (const value of Object.values(row)) {
|
|
270
|
+
if (typeof value === "string" && value.toLowerCase().includes(needle)) return true;
|
|
271
|
+
if (typeof value === "number" && String(value).includes(needle)) return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Does this row belong in the result set for `params`, ignoring pagination? */
|
|
277
|
+
export function matchesParams(row: Record<string, unknown>, params?: FindParams): boolean {
|
|
278
|
+
if (!params) return true;
|
|
279
|
+
return matchesWhere(row, params.where)
|
|
280
|
+
&& matchesLogical(row, params.logical)
|
|
281
|
+
&& matchesSearch(row, params.searchString);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Sort in place, Postgres-style: nulls last ascending, first descending, with
|
|
286
|
+
* the row id as a tiebreak so paging through an unsorted-but-equal run does
|
|
287
|
+
* not shuffle rows between pages.
|
|
288
|
+
*/
|
|
289
|
+
export function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderByTuple): M[] {
|
|
290
|
+
if (!orderBy) return rows;
|
|
291
|
+
const [field, direction = "asc"] = orderBy;
|
|
292
|
+
const sign = direction === "desc" ? -1 : 1;
|
|
293
|
+
return rows.sort((a, b) => {
|
|
294
|
+
const av = a[field];
|
|
295
|
+
const bv = b[field];
|
|
296
|
+
const aNull = isNullish(toComparable(av));
|
|
297
|
+
const bNull = isNullish(toComparable(bv));
|
|
298
|
+
if (aNull || bNull) {
|
|
299
|
+
if (aNull && bNull) return tiebreak(a, b);
|
|
300
|
+
// NULLS LAST ascending, NULLS FIRST descending.
|
|
301
|
+
return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
|
|
302
|
+
}
|
|
303
|
+
const cmp = compareValues(av, bv);
|
|
304
|
+
if (cmp === undefined || cmp === 0) return tiebreak(a, b);
|
|
305
|
+
return cmp * sign;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): number {
|
|
310
|
+
const cmp = compareValues(a.id, b.id);
|
|
311
|
+
return cmp ?? 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Resolve `page`/`offset`/`limit` the way the server does. */
|
|
315
|
+
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);
|
|
320
|
+
return { limit, offset };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
325
|
+
* assuming the cache holds every row of the collection?
|
|
326
|
+
*
|
|
327
|
+
* `include` pulls in rows from other collections that this evaluator never
|
|
328
|
+
* sees, and `searchString` is only approximated — both make the local answer a
|
|
329
|
+
* best effort rather than an equivalent one.
|
|
330
|
+
*/
|
|
331
|
+
export function isExactlyEvaluable(params?: FindParams): boolean {
|
|
332
|
+
if (!params) return true;
|
|
333
|
+
if (params.include && params.include.length > 0) return false;
|
|
334
|
+
if (params.searchString) return false;
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
339
|
+
export function runLocalQuery<M extends Record<string, unknown>>(
|
|
340
|
+
rows: M[],
|
|
341
|
+
params?: FindParams
|
|
342
|
+
): FindResult<M> {
|
|
343
|
+
const matched = rows.filter((row) => matchesParams(row, params));
|
|
344
|
+
sortRows(matched, params?.orderBy);
|
|
345
|
+
const { limit, offset } = resolvePagination(params);
|
|
346
|
+
const page = matched.slice(offset, offset + limit);
|
|
347
|
+
return {
|
|
348
|
+
data: page,
|
|
349
|
+
meta: {
|
|
350
|
+
total: matched.length,
|
|
351
|
+
limit,
|
|
352
|
+
offset,
|
|
353
|
+
hasMore: offset + page.length < matched.length
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|