@protobase/query 0.12.21 → 0.13.0
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/cursor.js +15 -9
- package/dist/filter-fingerprint.js +24 -0
- package/dist/list-query.js +2 -2
- package/dist/scroll-anchors.js +1 -1
- package/dist/scroll-exact.js +1 -1
- package/package.json +2 -2
package/dist/cursor.js
CHANGED
|
@@ -1,37 +1,43 @@
|
|
|
1
1
|
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { filterFingerprint } from "./filter-fingerprint.js";
|
|
2
3
|
import { sortSignature } from "./sort.js";
|
|
3
4
|
//#region src/cursor.ts
|
|
4
|
-
const invalidCursor = () => new QueryError("invalid_cursor", "Cursor is malformed or does not match the requested sort");
|
|
5
|
+
const invalidCursor = () => new QueryError("invalid_cursor", "Cursor is malformed or does not match the requested sort and filter");
|
|
5
6
|
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
6
7
|
const isCellList = (value) => Array.isArray(value) && value.every((item) => item === null || typeof item === "string");
|
|
7
8
|
/**
|
|
8
9
|
* Opaque cursor: the sort signature plus the row's sort values as text (so timestamps and bigints round-trip exactly,
|
|
9
|
-
* null for NULL values)
|
|
10
|
+
* null for NULL values), and a fingerprint of the filter when there is one, so a cursor only continues the list it
|
|
11
|
+
* came from (AIP-158). The JSON is UTF-8 encoded before base64url, so any text value survives.
|
|
10
12
|
* `values` may be a prefix of the sort keys; such a boundary cursor is produced by scroll anchors.
|
|
11
13
|
*/
|
|
12
|
-
const encodeCursor = (keys, values) => {
|
|
14
|
+
const encodeCursor = (keys, values, filter) => {
|
|
15
|
+
const fingerprint = filterFingerprint(filter);
|
|
13
16
|
const payload = {
|
|
14
17
|
s: sortSignature(keys).slice(0, values.length),
|
|
15
|
-
v: values
|
|
18
|
+
v: values,
|
|
19
|
+
...fingerprint && { f: fingerprint }
|
|
16
20
|
};
|
|
17
21
|
return toBase64Url(JSON.stringify(payload));
|
|
18
22
|
};
|
|
19
|
-
const decodeCursor = (cursor, keys) => {
|
|
23
|
+
const decodeCursor = (cursor, keys, filter) => {
|
|
20
24
|
const payload = parse(cursor);
|
|
21
25
|
const signature = sortSignature(keys);
|
|
22
26
|
const prefixOk = payload.s.length === payload.v.length && payload.s.every((part, i) => part === signature[i]);
|
|
23
27
|
const lengthOk = payload.v.length === keys.length || payload.v.length === 1;
|
|
24
|
-
|
|
28
|
+
const filterOk = (payload.f ?? "") === filterFingerprint(filter);
|
|
29
|
+
if (!prefixOk || !lengthOk || !filterOk) throw invalidCursor();
|
|
25
30
|
return payload.v;
|
|
26
31
|
};
|
|
27
32
|
const parse = (cursor) => {
|
|
28
33
|
const payload = parseJson(cursor);
|
|
29
34
|
if (typeof payload !== "object" || payload === null) throw invalidCursor();
|
|
30
|
-
const { s, v } = payload;
|
|
31
|
-
if (!isStringList(s) || !isCellList(v)) throw invalidCursor();
|
|
35
|
+
const { s, v, f } = payload;
|
|
36
|
+
if (!isStringList(s) || !isCellList(v) || f !== void 0 && typeof f !== "string") throw invalidCursor();
|
|
32
37
|
return {
|
|
33
38
|
s,
|
|
34
|
-
v
|
|
39
|
+
v,
|
|
40
|
+
...f !== void 0 && { f }
|
|
35
41
|
};
|
|
36
42
|
};
|
|
37
43
|
const toBase64Url = (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text))).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/filter-fingerprint.ts
|
|
2
|
+
/** The filter's structure without source spans, each field named by its column, so equal filters match however they were written. */
|
|
3
|
+
const canonical = (filter) => JSON.stringify(filter, (key, value) => {
|
|
4
|
+
if (key === "span") return void 0;
|
|
5
|
+
if (key === "field") return value.column;
|
|
6
|
+
return value;
|
|
7
|
+
});
|
|
8
|
+
/** cyrb53, a fast 53-bit string hash; the fingerprint guards against mistakes, not forgery. */
|
|
9
|
+
const hash = (text) => {
|
|
10
|
+
let h1 = 3735928559;
|
|
11
|
+
let h2 = 1103547991;
|
|
12
|
+
for (let i = 0; i < text.length; i++) {
|
|
13
|
+
const code = text.charCodeAt(i);
|
|
14
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
15
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
16
|
+
}
|
|
17
|
+
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
|
|
18
|
+
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
|
|
19
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
20
|
+
};
|
|
21
|
+
/** A short fingerprint of the filter a cursor was issued for; empty without a filter. */
|
|
22
|
+
const filterFingerprint = (filter) => filter ? hash(canonical(filter)) : "";
|
|
23
|
+
//#endregion
|
|
24
|
+
export { filterFingerprint };
|
package/dist/list-query.js
CHANGED
|
@@ -33,7 +33,7 @@ const buildListQuery = async (db, model, query, scope, options = {}) => {
|
|
|
33
33
|
const limit = clampLimit(query.limit);
|
|
34
34
|
await assertFilterRequirements(db, query.filter, scope);
|
|
35
35
|
const keys = effectiveSort(model, query.sort);
|
|
36
|
-
const cursorValues = query.cursor === void 0 ? void 0 : decodeCursor(query.cursor, keys);
|
|
36
|
+
const cursorValues = query.cursor === void 0 ? void 0 : decodeCursor(query.cursor, keys, query.filter);
|
|
37
37
|
return {
|
|
38
38
|
query: selectPage(db, model, query, scope, keys, cursorValues, options),
|
|
39
39
|
limit,
|
|
@@ -48,7 +48,7 @@ const listQuery = async (db, model, query, scope, options = {}) => {
|
|
|
48
48
|
const more = fetched.length > limit;
|
|
49
49
|
const page = more ? fetched.slice(0, limit) : fetched;
|
|
50
50
|
if (reverse) page.reverse();
|
|
51
|
-
const cursorOf = (row) => encodeCursor(keys, keys.map((_, i) => row[keyAlias(i)] === null ? null : String(row[keyAlias(i)])));
|
|
51
|
+
const cursorOf = (row) => encodeCursor(keys, keys.map((_, i) => row[keyAlias(i)] === null ? null : String(row[keyAlias(i)])), query.filter);
|
|
52
52
|
const first = page[0];
|
|
53
53
|
const last = page.at(-1);
|
|
54
54
|
return {
|
package/dist/scroll-anchors.js
CHANGED
|
@@ -60,7 +60,7 @@ const scrollAnchors = async (db, model, sortField, options = {}) => {
|
|
|
60
60
|
seekToPosition: async (position) => {
|
|
61
61
|
if (position <= 0) return void 0;
|
|
62
62
|
const anchor = anchors.findLast((candidate) => candidate.position <= position);
|
|
63
|
-
return anchor && anchor.position > 0 ? encodeCursor(keys, [anchor.value]) : void 0;
|
|
63
|
+
return anchor && anchor.position > 0 ? encodeCursor(keys, [anchor.value], filter) : void 0;
|
|
64
64
|
}
|
|
65
65
|
};
|
|
66
66
|
};
|
package/dist/scroll-exact.js
CHANGED
|
@@ -21,7 +21,7 @@ const exactSeek = async (db, model, keys, scope, filter, position) => {
|
|
|
21
21
|
if (position <= 0) return void 0;
|
|
22
22
|
const row = await db.selectFrom(rankedRows(db, model, keys, scope, filter).as("r")).selectAll().where(sql`r.rn = ${position}`).executeTakeFirst();
|
|
23
23
|
if (!row) return void 0;
|
|
24
|
-
return encodeCursor(keys, keys.map((_, i) => row[`k${i}`] === null ? null : String(row[`k${i}`])));
|
|
24
|
+
return encodeCursor(keys, keys.map((_, i) => row[`k${i}`] === null ? null : String(row[`k${i}`])), filter);
|
|
25
25
|
};
|
|
26
26
|
//#endregion
|
|
27
27
|
export { exactAnchors, exactSeek };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/query",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Kysely list, filter, count and aggregate queries over Protobase resources.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@protobase/schema": "0.
|
|
32
|
+
"@protobase/schema": "0.13.0",
|
|
33
33
|
"kysely": "^0.29.6"
|
|
34
34
|
}
|
|
35
35
|
}
|