@protobase/query 0.12.22 → 0.13.3

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.
@@ -0,0 +1,7 @@
1
+ import { Db } from "./db.js";
2
+ import { Scope } from "./scope.js";
3
+ import { CheckedFilter, ResourceModel } from "@protobase/schema";
4
+ //#region src/count-up-to.d.ts
5
+ /** How many rows match, counting no further than `limit`: enough to tell a few rows from many without a full count. */
6
+ export declare const countUpTo: (db: Db, model: ResourceModel, limit: number, filter?: CheckedFilter, scope?: Scope) => Promise<number>;
7
+ //#endregion
@@ -0,0 +1,14 @@
1
+ import { baseQuery } from "./base-query.js";
2
+ import { assertFilterRequirements } from "./filter-requirements.js";
3
+ import { toNumber } from "./numbers.js";
4
+ import { sql } from "kysely";
5
+ //#region src/count-up-to.ts
6
+ /** How many rows match, counting no further than `limit`: enough to tell a few rows from many without a full count. */
7
+ const countUpTo = async (db, model, limit, filter, scope) => {
8
+ await assertFilterRequirements(db, filter, scope);
9
+ const found = baseQuery(db, model, filter, scope).select(sql`1`.as("one")).limit(limit).as("found");
10
+ const row = await db.selectFrom(found).select(sql`count(*)::text`.as("n")).executeTakeFirstOrThrow();
11
+ return toNumber(row.n);
12
+ };
13
+ //#endregion
14
+ export { countUpTo };
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). The JSON is UTF-8 encoded before base64url, so any text value survives.
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
- if (!prefixOk || !lengthOk) throw invalidCursor();
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/index.d.ts CHANGED
@@ -4,11 +4,13 @@ import { Scope } from "./scope.js";
4
4
  import { ExtraColumn, ListOptions, ListPlan, ListResult, buildListQuery, listQuery, maxLimit } from "./list-query.js";
5
5
  import { estimateCount } from "./estimate-count.js";
6
6
  import { exactCount } from "./exact-count.js";
7
+ import { countUpTo } from "./count-up-to.js";
7
8
  import { FacetCount, FacetOptions, facetCounts } from "./facet-counts.js";
8
9
  import { Granularity, SeriesOptions, SeriesPoint, series } from "./series.js";
9
10
  import { Histogram, HistogramBucket, histogram } from "./histogram.js";
11
+ import { MapBounds, MapBucket, MapBuckets, MapPosition, cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
10
12
  import { ScrollMode, scrollMode } from "./scroll-mode.js";
11
13
  import { GuardFinding, GuardOptions, GuardReport, explainGuard } from "./explain-guard.js";
12
14
  import { QueryError, QueryErrorCode } from "./fields/query-error.js";
13
15
  import { SeekRequest, seekToPosition } from "./seek-to-position.js";
14
- export { type CompileOptions, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type GuardFinding, type GuardOptions, type GuardReport, type Histogram, type HistogramBucket, type ListOptions, type ListPlan, type ListResult, QueryError, type QueryErrorCode, type Scope, type ScrollMode, type SeekRequest, type SeriesOptions, type SeriesPoint, buildListQuery, compileFilter, estimateCount, exactCount, explainGuard, facetCounts, histogram, listQuery, maxLimit, scrollMode, seekToPosition, series };
16
+ export { type CompileOptions, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type GuardFinding, type GuardOptions, type GuardReport, type Histogram, type HistogramBucket, type ListOptions, type ListPlan, type ListResult, type MapBounds, type MapBucket, type MapBuckets, type MapPosition, QueryError, type QueryErrorCode, type Scope, type ScrollMode, type SeekRequest, type SeriesOptions, type SeriesPoint, buildListQuery, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, explainGuard, facetCounts, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, scrollMode, seekToPosition, series };
package/dist/index.js CHANGED
@@ -3,10 +3,12 @@ import { compileFilter } from "./compile-filter.js";
3
3
  import { buildListQuery, listQuery, maxLimit } from "./list-query.js";
4
4
  import { estimateCount } from "./estimate-count.js";
5
5
  import { exactCount } from "./exact-count.js";
6
+ import { countUpTo } from "./count-up-to.js";
6
7
  import { facetCounts } from "./facet-counts.js";
7
8
  import { series } from "./series.js";
8
9
  import { histogram } from "./histogram.js";
10
+ import { cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
9
11
  import { scrollMode } from "./scroll-mode.js";
10
12
  import { explainGuard } from "./explain-guard.js";
11
13
  import { seekToPosition } from "./seek-to-position.js";
12
- export { QueryError, buildListQuery, compileFilter, estimateCount, exactCount, explainGuard, facetCounts, histogram, listQuery, maxLimit, scrollMode, seekToPosition, series };
14
+ export { QueryError, buildListQuery, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, explainGuard, facetCounts, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, scrollMode, seekToPosition, series };
@@ -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 {
@@ -0,0 +1,48 @@
1
+ import { Db } from "./db.js";
2
+ import { Scope } from "./scope.js";
3
+ import { CheckedFilter, ResourceModel } from "@protobase/schema";
4
+ //#region src/map-buckets.d.ts
5
+ /** MapLibre's zoom levels, from the whole world at 0 to a building at 22. */
6
+ export declare const maxMapZoom = 22;
7
+ /** About how many pixels across a bucket's cell is on the map. */
8
+ export declare const mapCellPixels = 64;
9
+ /** The most buckets one request returns, the fullest first; a large screen's map holds fewer cells. */
10
+ export declare const maxMapBuckets = 1000;
11
+ /** The fields that hold a record's position, in decimal degrees. */
12
+ export type MapPosition = {
13
+ latitude: string;
14
+ longitude: string;
15
+ };
16
+ /** The corners of the records a bucket stands for, in decimal degrees. */
17
+ export type MapBounds = {
18
+ south: number;
19
+ west: number;
20
+ north: number;
21
+ east: number;
22
+ };
23
+ /** The records in one cell of the map's grid: how many, their mean position, and the bounds around them. */
24
+ export type MapBucket = {
25
+ count: number;
26
+ latitude: number;
27
+ longitude: number;
28
+ bounds: MapBounds;
29
+ };
30
+ /** The buckets, and how many records they hold together, the ones beyond `maxMapBuckets` included. */
31
+ export type MapBuckets = {
32
+ total: number;
33
+ buckets: MapBucket[];
34
+ };
35
+ /** How many cells span the world's width, and its Web Mercator height, at a zoom. */
36
+ export declare const cellsAcross: (zoom: number) => number;
37
+ /** The position's fields, refused unless each is a filterable number of degrees. */
38
+ export declare const checkMapPosition: (model: ResourceModel, position: MapPosition) => {
39
+ latitude: import("@protobase/schema").FieldModel;
40
+ longitude: import("@protobase/schema").FieldModel;
41
+ };
42
+ /**
43
+ * The matching records with a position, counted per cell of a grid that is square on the map at `zoom` (Web
44
+ * Mercator, as MapLibre draws it) and about `mapCellPixels` across. The grid starts at the antimeridian and the north
45
+ * edge, so a cell stays put as the map pans. Postgres groups the rows, so the filter can use the position's indexes.
46
+ */
47
+ export declare const mapBuckets: (db: Db, model: ResourceModel, position: MapPosition, zoom: number, filter?: CheckedFilter, scope?: Scope) => Promise<MapBuckets>;
48
+ //#endregion
@@ -0,0 +1,75 @@
1
+ import { QueryError } from "./fields/query-error.js";
2
+ import { columnRef, requireField } from "./columns.js";
3
+ import { baseQuery } from "./base-query.js";
4
+ import { assertFilterRequirements } from "./filter-requirements.js";
5
+ import { toNumber } from "./numbers.js";
6
+ import { sql } from "kysely";
7
+ //#region src/map-buckets.ts
8
+ /** MapLibre's zoom levels, from the whole world at 0 to a building at 22. */
9
+ const maxMapZoom = 22;
10
+ /** About how many pixels across a bucket's cell is on the map. */
11
+ const mapCellPixels = 64;
12
+ /** The most buckets one request returns, the fullest first; a large screen's map holds fewer cells. */
13
+ const maxMapBuckets = 1e3;
14
+ const worldPixels = 512;
15
+ const maxLatitude = 85.051129;
16
+ const coordinateTypes = [
17
+ "integer",
18
+ "bigint",
19
+ "decimal"
20
+ ];
21
+ /** How many cells span the world's width, and its Web Mercator height, at a zoom. */
22
+ const cellsAcross = (zoom) => worldPixels * 2 ** zoom / 64;
23
+ const coordinate = (model, name) => {
24
+ const target = requireField(model, name, "filterable");
25
+ if (!coordinateTypes.includes(target.type)) throw new QueryError("unsupported_field_type", `A position needs number fields, "${name}" is ${target.type}`);
26
+ return target;
27
+ };
28
+ /** The position's fields, refused unless each is a filterable number of degrees. */
29
+ const checkMapPosition = (model, position) => ({
30
+ latitude: coordinate(model, position.latitude),
31
+ longitude: coordinate(model, position.longitude)
32
+ });
33
+ /**
34
+ * The matching records with a position, counted per cell of a grid that is square on the map at `zoom` (Web
35
+ * Mercator, as MapLibre draws it) and about `mapCellPixels` across. The grid starts at the antimeridian and the north
36
+ * edge, so a cell stays put as the map pans. Postgres groups the rows, so the filter can use the position's indexes.
37
+ */
38
+ const mapBuckets = async (db, model, position, zoom, filter, scope) => {
39
+ const fields = checkMapPosition(model, position);
40
+ if (!Number.isInteger(zoom) || zoom < 0 || zoom > 22) throw new QueryError("invalid_option", `zoom must be a whole number from 0 to 22`);
41
+ await assertFilterRequirements(db, filter, scope);
42
+ const latitude = sql`${columnRef(fields.latitude)}::float8`;
43
+ const longitude = sql`${columnRef(fields.longitude)}::float8`;
44
+ const cells = cellsAcross(zoom);
45
+ const last = cells - 1;
46
+ const clamped = sql`least(greatest(${latitude}, ${-85.051129}::float8), ${maxLatitude}::float8)`;
47
+ const column = sql`least(floor((${longitude} + 180) / 360 * ${cells}::float8), ${last}::float8)`;
48
+ const row = sql`least(floor((1 - ln(tan(pi() / 4 + radians(${clamped}) / 2)) / pi()) / 2 * ${cells}::float8), ${last}::float8)`;
49
+ const rows = await baseQuery(db, model, filter, scope).where(sql`${columnRef(fields.latitude)} is not null and ${columnRef(fields.longitude)} is not null`).select([
50
+ sql`count(*)::text`.as("n"),
51
+ sql`(sum(count(*)) over ())::text`.as("total"),
52
+ sql`avg(${latitude})::text`.as("latitude"),
53
+ sql`avg(${longitude})::text`.as("longitude"),
54
+ sql`min(${latitude})::text`.as("south"),
55
+ sql`min(${longitude})::text`.as("west"),
56
+ sql`max(${latitude})::text`.as("north"),
57
+ sql`max(${longitude})::text`.as("east")
58
+ ]).groupBy([column, row]).orderBy(sql`count(*)`, "desc").limit(maxMapBuckets).execute();
59
+ return {
60
+ total: rows.length === 0 ? 0 : toNumber(rows[0].total),
61
+ buckets: rows.map((bucket) => ({
62
+ count: toNumber(bucket.n),
63
+ latitude: toNumber(bucket.latitude),
64
+ longitude: toNumber(bucket.longitude),
65
+ bounds: {
66
+ south: toNumber(bucket.south),
67
+ west: toNumber(bucket.west),
68
+ north: toNumber(bucket.north),
69
+ east: toNumber(bucket.east)
70
+ }
71
+ }))
72
+ };
73
+ };
74
+ //#endregion
75
+ export { cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom };
@@ -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
  };
@@ -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.12.22",
3
+ "version": "0.13.3",
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.12.22",
32
+ "@protobase/schema": "0.13.3",
33
33
  "kysely": "^0.29.6"
34
34
  }
35
35
  }