@protobase/query 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/dist/anchor-positions.js +37 -0
- package/dist/base-query.js +26 -0
- package/dist/columns.js +26 -0
- package/dist/compile-compare.js +25 -0
- package/dist/compile-filter.d.ts +10 -0
- package/dist/compile-filter.js +35 -0
- package/dist/compile-text-match.js +48 -0
- package/dist/cursor.js +52 -0
- package/dist/db.d.ts +5 -0
- package/dist/db.js +5 -0
- package/dist/estimate-count.d.ts +10 -0
- package/dist/estimate-count.js +35 -0
- package/dist/exact-count.d.ts +7 -0
- package/dist/exact-count.js +13 -0
- package/dist/explain-guard.d.ts +21 -0
- package/dist/explain-guard.js +42 -0
- package/dist/explain.js +10 -0
- package/dist/facet-counts.d.ts +16 -0
- package/dist/facet-counts.js +27 -0
- package/dist/field-values.js +36 -0
- package/dist/fields/bigint.js +13 -0
- package/dist/fields/boolean.js +8 -0
- package/dist/fields/country.js +5 -0
- package/dist/fields/currency.js +5 -0
- package/dist/fields/date.js +11 -0
- package/dist/fields/decimal.js +9 -0
- package/dist/fields/enum.js +9 -0
- package/dist/fields/integer.js +9 -0
- package/dist/fields/json.js +15 -0
- package/dist/fields/query-error.d.ts +8 -0
- package/dist/fields/query-error.js +12 -0
- package/dist/fields/relation.js +10 -0
- package/dist/fields/sql-common.js +30 -0
- package/dist/fields/text.js +10 -0
- package/dist/fields/timestamp.js +10 -0
- package/dist/fields/uuid.js +10 -0
- package/dist/filter-requirements.js +15 -0
- package/dist/histogram.d.ts +17 -0
- package/dist/histogram.js +57 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/list-query.d.ts +40 -0
- package/dist/list-query.js +61 -0
- package/dist/numbers.js +5 -0
- package/dist/order.js +14 -0
- package/dist/prune-filter.js +26 -0
- package/dist/range-bound.js +15 -0
- package/dist/scope.d.ts +17 -0
- package/dist/scope.js +17 -0
- package/dist/scroll-anchors.js +68 -0
- package/dist/scroll-exact.js +27 -0
- package/dist/scroll-mode.d.ts +4 -0
- package/dist/scroll-mode.js +10 -0
- package/dist/scroll-stats.js +27 -0
- package/dist/seek-to-position.d.ts +20 -0
- package/dist/seek-to-position.js +30 -0
- package/dist/seek.js +52 -0
- package/dist/series.d.ts +33 -0
- package/dist/series.js +63 -0
- package/dist/sort.d.ts +8 -0
- package/dist/sort.js +33 -0
- package/dist/with-statement-timeout.js +32 -0
- package/package.json +31 -3
- package/README.md +0 -3
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { requireField } from "./columns.js";
|
|
3
|
+
import { effectiveSort } from "./sort.js";
|
|
4
|
+
import { encodeCursor } from "./cursor.js";
|
|
5
|
+
import { estimateCount } from "./estimate-count.js";
|
|
6
|
+
import { exactCount } from "./exact-count.js";
|
|
7
|
+
import { scrollMode } from "./scroll-mode.js";
|
|
8
|
+
import { ascendingAnchors, descendingAnchors } from "./anchor-positions.js";
|
|
9
|
+
import { exactAnchors, exactSeek } from "./scroll-exact.js";
|
|
10
|
+
import { readColumnStats } from "./scroll-stats.js";
|
|
11
|
+
//#region src/scroll-anchors.ts
|
|
12
|
+
const defaultAnchorCount = 100;
|
|
13
|
+
/**
|
|
14
|
+
* Internal: statistics-mode anchors are table-wide pg_stats values, which may belong to rows the caller cannot see,
|
|
15
|
+
* so no route may return them; use seekToPosition, which only returns visible rows.
|
|
16
|
+
* Scrollbar support for a sort on `sortField` (primary key as tie-breaker, as in listQuery).
|
|
17
|
+
* exact: window-function positions (under 100k rows). statistics: pg_stats histogram, positions are approximate.
|
|
18
|
+
* With a filter, exact mode ranks only matching rows. Statistics mode scales the table-wide positions by the planner's
|
|
19
|
+
* selectivity estimate for the filter (filtered estimate / table estimate), so they are rougher still.
|
|
20
|
+
* With a tenant scope, exact mode is exact within the tenant; statistics mode scales the table-wide histogram to the
|
|
21
|
+
* tenant's row count, which assumes the tenant's values are distributed like the table's.
|
|
22
|
+
* In statistics mode seekToPosition returns a boundary cursor on the leading sort column, which listQuery accepts
|
|
23
|
+
* for any sort that begins with the same field and direction.
|
|
24
|
+
*/
|
|
25
|
+
const scrollAnchors = async (db, model, sortField, options = {}) => {
|
|
26
|
+
const { direction = "asc", anchorCount = defaultAnchorCount, filter, scope, sort = [[sortField, direction]] } = options;
|
|
27
|
+
if (!Number.isInteger(anchorCount) || anchorCount < 2) throw new QueryError("invalid_option", "anchorCount must be at least 2");
|
|
28
|
+
const field = requireField(model, sortField, "sortable");
|
|
29
|
+
const keys = effectiveSort(model, sort);
|
|
30
|
+
const mode = scrollMode(await estimateCount(db, model, filter, scope));
|
|
31
|
+
if (mode === "sampled") throw new Error("Not implemented: sampled scroll mode for tables above 5M rows needs a sampling strategy, statistics are too coarse there");
|
|
32
|
+
if (mode === "exact") {
|
|
33
|
+
const totalRows = await exactCount(db, model, filter, scope);
|
|
34
|
+
const step = Math.max(1, Math.ceil(totalRows / anchorCount));
|
|
35
|
+
return {
|
|
36
|
+
mode,
|
|
37
|
+
totalRows,
|
|
38
|
+
anchors: await exactAnchors(db, model, keys, step, scope, filter),
|
|
39
|
+
mostCommon: [],
|
|
40
|
+
seekToPosition: (position) => exactSeek(db, model, keys, scope, filter, Math.min(position, totalRows - 1))
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const stats = await readColumnStats(db, model, field);
|
|
44
|
+
const tableRows = await estimateCount(db, model, void 0, scope);
|
|
45
|
+
const totalRows = filter ? await estimateCount(db, model, filter, scope) : tableRows;
|
|
46
|
+
const selectivity = tableRows > 0 ? Math.min(1, totalRows / tableRows) : 1;
|
|
47
|
+
const ascending = ascendingAnchors(stats, tableRows, field.type).map((anchor) => ({
|
|
48
|
+
...anchor,
|
|
49
|
+
position: Math.round(anchor.position * selectivity)
|
|
50
|
+
}));
|
|
51
|
+
const anchors = direction === "asc" ? ascending : descendingAnchors(ascending, totalRows);
|
|
52
|
+
return {
|
|
53
|
+
mode,
|
|
54
|
+
totalRows,
|
|
55
|
+
anchors,
|
|
56
|
+
mostCommon: stats.commonValues.map((value, i) => ({
|
|
57
|
+
value,
|
|
58
|
+
frequency: stats.commonFrequencies[i] ?? 0
|
|
59
|
+
})),
|
|
60
|
+
seekToPosition: async (position) => {
|
|
61
|
+
if (position <= 0) return void 0;
|
|
62
|
+
const anchor = anchors.findLast((candidate) => candidate.position <= position);
|
|
63
|
+
return anchor && anchor.position > 0 ? encodeCursor(keys, [anchor.value]) : void 0;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { scrollAnchors };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { columnRef } from "./columns.js";
|
|
2
|
+
import { baseQuery } from "./base-query.js";
|
|
3
|
+
import { encodeCursor } from "./cursor.js";
|
|
4
|
+
import { orderTerm } from "./order.js";
|
|
5
|
+
import { toNumber } from "./numbers.js";
|
|
6
|
+
import { sql } from "kysely";
|
|
7
|
+
//#region src/scroll-exact.ts
|
|
8
|
+
const rankedRows = (db, model, keys, scope, filter) => {
|
|
9
|
+
const order = sql.join(keys.map((key) => orderTerm(key)));
|
|
10
|
+
return baseQuery(db, model, filter, scope).select(keys.map((key, i) => sql`${columnRef(key.field)}::text`.as(`k${i}`))).select(sql`row_number() over (order by ${order})`.as("rn"));
|
|
11
|
+
};
|
|
12
|
+
/** Every `step`-th row's leading sort value with its exact 0-based position. */
|
|
13
|
+
const exactAnchors = async (db, model, keys, step, scope, filter) => {
|
|
14
|
+
return (await db.selectFrom(rankedRows(db, model, keys, scope, filter).as("r")).select([sql`r.k0`.as("value"), sql`r.rn::text`.as("rn")]).where(sql`(r.rn - 1) % ${step} = 0`).orderBy(sql`r.rn`).execute()).map((row) => ({
|
|
15
|
+
position: toNumber(row.rn) - 1,
|
|
16
|
+
value: String(row.value)
|
|
17
|
+
}));
|
|
18
|
+
};
|
|
19
|
+
/** Cursor for the row just before `position`, so a page read after it starts exactly at `position`. */
|
|
20
|
+
const exactSeek = async (db, model, keys, scope, filter, position) => {
|
|
21
|
+
if (position <= 0) return void 0;
|
|
22
|
+
const row = await db.selectFrom(rankedRows(db, model, keys, scope, filter).as("r")).selectAll().where(sql`r.rn = ${position}`).executeTakeFirst();
|
|
23
|
+
if (!row) return void 0;
|
|
24
|
+
return encodeCursor(keys, keys.map((_, i) => row[`k${i}`] === null ? null : String(row[`k${i}`])));
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { exactAnchors, exactSeek };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/scroll-mode.ts
|
|
2
|
+
const exactBelowRows = 1e5;
|
|
3
|
+
const statisticsUpToRows = 5e6;
|
|
4
|
+
const scrollMode = (rows) => {
|
|
5
|
+
if (rows < 1e5) return "exact";
|
|
6
|
+
if (rows <= 5e6) return "statistics";
|
|
7
|
+
return "sampled";
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { exactBelowRows, scrollMode, statisticsUpToRows };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { toNumber } from "./numbers.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/scroll-stats.ts
|
|
5
|
+
/** Reads pg_stats for one column; values are cast to text[] so any column type parses uniformly. */
|
|
6
|
+
const readColumnStats = async (db, model, field) => {
|
|
7
|
+
const row = (await sql`
|
|
8
|
+
select null_frac::float8 as null_frac,
|
|
9
|
+
histogram_bounds::text::text[] as bounds,
|
|
10
|
+
most_common_vals::text::text[] as common_vals,
|
|
11
|
+
most_common_freqs::float8[] as common_freqs
|
|
12
|
+
from pg_stats
|
|
13
|
+
where schemaname = coalesce(${model.table.schema ?? null}::text, current_schema())
|
|
14
|
+
and tablename = ${model.table.name} and attname = ${field.column}
|
|
15
|
+
order by inherited
|
|
16
|
+
limit 1
|
|
17
|
+
`.execute(db)).rows[0];
|
|
18
|
+
if (!row?.bounds || row.bounds.length < 2) throw new QueryError("no_statistics", `No histogram for "${field.name}"; run ANALYZE or sort by a higher-cardinality column`);
|
|
19
|
+
return {
|
|
20
|
+
nullFraction: toNumber(row.null_frac),
|
|
21
|
+
bounds: row.bounds,
|
|
22
|
+
commonValues: row.common_vals ?? [],
|
|
23
|
+
commonFrequencies: (row.common_freqs ?? []).map(toNumber)
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { readColumnStats };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { Scope } from "./scope.js";
|
|
3
|
+
import { ListOptions, ListResult } from "./list-query.js";
|
|
4
|
+
import { CheckedFilter, ResourceModel, SortSpec } from "@protobase/schema";
|
|
5
|
+
//#region src/seek-to-position.d.ts
|
|
6
|
+
export type SeekRequest = {
|
|
7
|
+
/** 0-based row position within the caller's visible rows, in `orderBy` order. */
|
|
8
|
+
position: number;
|
|
9
|
+
orderBy: SortSpec;
|
|
10
|
+
filter?: CheckedFilter;
|
|
11
|
+
columns?: string[];
|
|
12
|
+
limit?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a scroll position to a page of rows, server-side, with no OFFSET. Anchors are used internally only:
|
|
16
|
+
* positions are scaled to the caller's scoped estimate, and the page is read with the full scope (tenant, row filter,
|
|
17
|
+
* user filter), so it holds only visible rows. `options` (extra columns) pass through to the page read. The result carries page tokens for the visible rows, never anchor values.
|
|
18
|
+
*/
|
|
19
|
+
export declare const seekToPosition: (db: Db, model: ResourceModel, request: SeekRequest, scope?: Scope, options?: ListOptions) => Promise<ListResult>;
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { listQuery } from "./list-query.js";
|
|
3
|
+
import { scrollAnchors } from "./scroll-anchors.js";
|
|
4
|
+
//#region src/seek-to-position.ts
|
|
5
|
+
const defaultLimit = 50;
|
|
6
|
+
/**
|
|
7
|
+
* Resolves a scroll position to a page of rows, server-side, with no OFFSET. Anchors are used internally only:
|
|
8
|
+
* positions are scaled to the caller's scoped estimate, and the page is read with the full scope (tenant, row filter,
|
|
9
|
+
* user filter), so it holds only visible rows. `options` (extra columns) pass through to the page read. The result carries page tokens for the visible rows, never anchor values.
|
|
10
|
+
*/
|
|
11
|
+
const seekToPosition = async (db, model, request, scope, options = {}) => {
|
|
12
|
+
const [lead] = request.orderBy;
|
|
13
|
+
if (!lead) throw new QueryError("invalid_sort", "orderBy needs at least one field");
|
|
14
|
+
if (!Number.isInteger(request.position) || request.position < 0) throw new QueryError("invalid_option", "position must be a non-negative integer");
|
|
15
|
+
const cursor = await (await scrollAnchors(db, model, lead[0], {
|
|
16
|
+
direction: lead[1],
|
|
17
|
+
sort: request.orderBy,
|
|
18
|
+
filter: request.filter,
|
|
19
|
+
scope
|
|
20
|
+
})).seekToPosition(request.position);
|
|
21
|
+
return listQuery(db, model, {
|
|
22
|
+
limit: request.limit ?? defaultLimit,
|
|
23
|
+
sort: request.orderBy,
|
|
24
|
+
filter: request.filter,
|
|
25
|
+
columns: request.columns,
|
|
26
|
+
cursor
|
|
27
|
+
}, scope, options);
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { seekToPosition };
|
package/dist/seek.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { columnRef } from "./columns.js";
|
|
2
|
+
import { flip } from "./sort.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/seek.ts
|
|
5
|
+
const compare = (direction, inclusive) => sql.raw(direction === "asc" ? inclusive ? ">=" : ">" : inclusive ? "<=" : "<");
|
|
6
|
+
/**
|
|
7
|
+
* Rows positioned beyond `value` when reading in `direction` (asc puts nulls last, desc puts them first).
|
|
8
|
+
* `inclusive` also keeps rows equal to value; for a null value that means the null rows themselves.
|
|
9
|
+
*/
|
|
10
|
+
const beyond = (key, direction, value, inclusive) => {
|
|
11
|
+
const column = columnRef(key.field);
|
|
12
|
+
if (value === null) {
|
|
13
|
+
if (direction === "asc") return inclusive ? sql`${column} is null` : sql`false`;
|
|
14
|
+
return inclusive ? sql`true` : sql`${column} is not null`;
|
|
15
|
+
}
|
|
16
|
+
const ordered = sql`${column} ${compare(direction, inclusive)} ${value}`;
|
|
17
|
+
return key.field.nullable && direction === "asc" ? sql`(${ordered} or ${column} is null)` : ordered;
|
|
18
|
+
};
|
|
19
|
+
const same = (key, value) => {
|
|
20
|
+
const column = columnRef(key.field);
|
|
21
|
+
return value === null ? sql`${column} is null` : sql`${column} = ${value}`;
|
|
22
|
+
};
|
|
23
|
+
/** Rows from a boundary cursor (leading column only): `after` starts at the value, `before` ends just short of it. */
|
|
24
|
+
const seekBoundary = (key, value, reading) => reading === "after" ? beyond(key, key.direction, value, true) : beyond(key, flip(key.direction), value, false);
|
|
25
|
+
/** Expanded form: (a beyond x) or (a = x and b beyond y) or ... Handles mixed directions and NULL boundaries. */
|
|
26
|
+
const seekExpanded = (keys, values) => {
|
|
27
|
+
const branches = keys.map((key, i) => {
|
|
28
|
+
const equal = keys.slice(0, i).map((prior, j) => same(prior, values[j] ?? null));
|
|
29
|
+
return sql`(${sql.join([...equal, beyond(key, key.direction, values[i] ?? null, false)], sql` and `)})`;
|
|
30
|
+
});
|
|
31
|
+
return sql`(${sql.join(branches, sql` or `)})`;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Condition selecting rows strictly after (or before) the cursor row in the requested sort.
|
|
35
|
+
* `keys` carry the user-facing directions; `reading` only picks which side of the cursor to read.
|
|
36
|
+
* Row-value comparison is used when every direction matches and no key can be NULL (it cannot order NULLs).
|
|
37
|
+
*/
|
|
38
|
+
const seekCondition = (keys, values, reading) => {
|
|
39
|
+
const [first] = keys;
|
|
40
|
+
if (values.length === 1 && keys.length > 1 && first) return seekBoundary(first, values[0] ?? null, reading);
|
|
41
|
+
const effective = keys.map((key) => ({
|
|
42
|
+
...key,
|
|
43
|
+
direction: reading === "after" ? key.direction : flip(key.direction)
|
|
44
|
+
}));
|
|
45
|
+
const uniform = effective.every((key) => key.direction === effective[0]?.direction);
|
|
46
|
+
const nullable = effective.some((key) => key.field.nullable);
|
|
47
|
+
if (!uniform || nullable) return seekExpanded(effective, values);
|
|
48
|
+
const cols = sql.join(effective.map((key) => columnRef(key.field)));
|
|
49
|
+
return sql`(${cols}) ${compare(effective[0].direction, false)} (${sql.join(values)})`;
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { seekCondition };
|
package/dist/series.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { Scope } from "./scope.js";
|
|
3
|
+
import { CheckedFilter, ResourceModel } from "@protobase/schema";
|
|
4
|
+
//#region src/series.d.ts
|
|
5
|
+
declare const approxMs: {
|
|
6
|
+
hour: number;
|
|
7
|
+
day: number;
|
|
8
|
+
week: number;
|
|
9
|
+
month: number;
|
|
10
|
+
year: number;
|
|
11
|
+
};
|
|
12
|
+
export type Granularity = keyof typeof approxMs;
|
|
13
|
+
export type SeriesOptions = {
|
|
14
|
+
range: {
|
|
15
|
+
from: string | Date;
|
|
16
|
+
to: string | Date;
|
|
17
|
+
};
|
|
18
|
+
granularity: Granularity;
|
|
19
|
+
timeZone?: string;
|
|
20
|
+
filter?: CheckedFilter;
|
|
21
|
+
scope?: Scope;
|
|
22
|
+
};
|
|
23
|
+
export type SeriesPoint = {
|
|
24
|
+
bucket: string;
|
|
25
|
+
count: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Row counts per calendar bucket, in `timeZone`, with empty buckets present. `to` is exclusive.
|
|
29
|
+
* Buckets are returned as local ISO strings without offset. `date` fields ignore the time zone;
|
|
30
|
+
* `timestamp` columns without a zone are read in the session time zone.
|
|
31
|
+
*/
|
|
32
|
+
export declare const series: (db: Db, model: ResourceModel, field: string, options: SeriesOptions) => Promise<SeriesPoint[]>;
|
|
33
|
+
//#endregion
|
package/dist/series.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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 { rangeBound } from "./range-bound.js";
|
|
7
|
+
import { sql } from "kysely";
|
|
8
|
+
//#region src/series.ts
|
|
9
|
+
const maxBuckets = 1e4;
|
|
10
|
+
const approxMs = {
|
|
11
|
+
hour: 36e5,
|
|
12
|
+
day: 864e5,
|
|
13
|
+
week: 6048e5,
|
|
14
|
+
month: 26298e5,
|
|
15
|
+
year: 315576e5
|
|
16
|
+
};
|
|
17
|
+
const checkTimeZone = (timeZone) => {
|
|
18
|
+
try {
|
|
19
|
+
new Intl.DateTimeFormat("en", { timeZone });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error instanceof RangeError) throw new QueryError("invalid_option", `Unknown time zone "${timeZone}"`);
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Row counts per calendar bucket, in `timeZone`, with empty buckets present. `to` is exclusive.
|
|
27
|
+
* Buckets are returned as local ISO strings without offset. `date` fields ignore the time zone;
|
|
28
|
+
* `timestamp` columns without a zone are read in the session time zone.
|
|
29
|
+
*/
|
|
30
|
+
const series = async (db, model, field, options) => {
|
|
31
|
+
const target = requireField(model, field, "filterable");
|
|
32
|
+
if (target.type !== "date" && target.type !== "timestamp") throw new QueryError("unsupported_field_type", `Series needs a date or timestamp field, "${field}" is ${target.type}`);
|
|
33
|
+
if (!Object.hasOwn(approxMs, options.granularity)) throw new QueryError("invalid_option", "Unknown granularity");
|
|
34
|
+
const timeZone = options.timeZone ?? "UTC";
|
|
35
|
+
checkTimeZone(timeZone);
|
|
36
|
+
const from = rangeBound(options.range.from, target);
|
|
37
|
+
const to = rangeBound(options.range.to, target);
|
|
38
|
+
const spanMs = new Date(to).getTime() - new Date(from).getTime();
|
|
39
|
+
if (!(spanMs > 0)) throw new QueryError("invalid_option", "range.to must be after range.from");
|
|
40
|
+
if (spanMs / approxMs[options.granularity] > maxBuckets) throw new QueryError("invalid_option", `Range spans more than ${maxBuckets} buckets`);
|
|
41
|
+
await assertFilterRequirements(db, options.filter, options.scope);
|
|
42
|
+
const col = columnRef(target);
|
|
43
|
+
const unit = sql.lit(options.granularity);
|
|
44
|
+
const step = sql`${sql.lit(`1 ${options.granularity}`)}::interval`;
|
|
45
|
+
const isDate = target.type === "date";
|
|
46
|
+
const bound = sql.raw(isDate ? "date" : "timestamptz");
|
|
47
|
+
const local = (value) => isDate ? sql`${value}::timestamp` : sql`(${value}::timestamptz at time zone ${timeZone}::text)`;
|
|
48
|
+
const laterBucket = sql`date_trunc(${unit}, ${local(to)} - interval '1 microsecond')`;
|
|
49
|
+
const counts = baseQuery(db, model, options.filter, options.scope).where(sql`${col} >= ${from}::${bound} and ${col} < ${to}::${bound}`).select([sql`date_trunc(${unit}, ${isDate ? sql`${col}::timestamp` : local(col)})`.as("bucket"), sql`count(*)::text`.as("n")]).groupBy(sql`1`);
|
|
50
|
+
return (await sql`
|
|
51
|
+
with b as (
|
|
52
|
+
select generate_series(date_trunc(${unit}, ${local(from)}), ${laterBucket}, ${step}) as bucket
|
|
53
|
+
), c as (${counts})
|
|
54
|
+
select to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:SS') as bucket, coalesce(c.n, '0') as n
|
|
55
|
+
from b left join c on c.bucket = b.bucket
|
|
56
|
+
order by b.bucket
|
|
57
|
+
`.execute(db)).rows.map((row) => ({
|
|
58
|
+
bucket: row.bucket,
|
|
59
|
+
count: toNumber(row.n)
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { series };
|
package/dist/sort.d.ts
ADDED
package/dist/sort.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { primaryKeyFields, requireField } from "./columns.js";
|
|
3
|
+
//#region src/sort.ts
|
|
4
|
+
const flip = (direction) => direction === "asc" ? "desc" : "asc";
|
|
5
|
+
const checkDirection = (direction) => {
|
|
6
|
+
if (direction === "asc" || direction === "desc") return direction;
|
|
7
|
+
throw new QueryError("invalid_sort", "Sort direction must be \"asc\" or \"desc\"");
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The requested sort with the primary key appended as final tie-breaker, so ordering is total.
|
|
11
|
+
* The tie-breaker follows the first sort direction, which keeps uniform sorts eligible for row-value seeks.
|
|
12
|
+
* Nullable columns sort NULLS LAST for asc and NULLS FIRST for desc (see order.ts and seek.ts).
|
|
13
|
+
*/
|
|
14
|
+
const effectiveSort = (model, sort = []) => {
|
|
15
|
+
const keys = [];
|
|
16
|
+
for (const [name, direction] of sort) {
|
|
17
|
+
const field = requireField(model, name, "sortable");
|
|
18
|
+
if (keys.some((key) => key.field === field)) continue;
|
|
19
|
+
keys.push({
|
|
20
|
+
field,
|
|
21
|
+
direction: checkDirection(direction)
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const tieBreaker = keys[0]?.direction ?? "asc";
|
|
25
|
+
for (const field of primaryKeyFields(model)) if (!keys.some((key) => key.field === field)) keys.push({
|
|
26
|
+
field,
|
|
27
|
+
direction: tieBreaker
|
|
28
|
+
});
|
|
29
|
+
return keys;
|
|
30
|
+
};
|
|
31
|
+
const sortSignature = (keys) => keys.map((key) => `${key.field.name}:${key.direction}`);
|
|
32
|
+
//#endregion
|
|
33
|
+
export { effectiveSort, flip, sortSignature };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/with-statement-timeout.ts
|
|
4
|
+
const setTimeout = (db, value) => sql`select set_config('statement_timeout', ${value}, true)`.execute(db);
|
|
5
|
+
/**
|
|
6
|
+
* Runs `run` with Postgres cancelling statements after `timeoutMs`. On a plain connection it opens its own
|
|
7
|
+
* transaction. Given a transaction (for example a read-only one) it sets the timeout locally inside it and
|
|
8
|
+
* restores the previous value afterwards, so the caller's other statements are unaffected.
|
|
9
|
+
*/
|
|
10
|
+
const withStatementTimeout = async (db, timeoutMs, run) => {
|
|
11
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) throw new QueryError("invalid_option", "statementTimeoutMs must be a positive integer");
|
|
12
|
+
if (!db.isTransaction) return db.transaction().execute(async (trx) => {
|
|
13
|
+
await setTimeout(trx, String(timeoutMs));
|
|
14
|
+
return run(trx);
|
|
15
|
+
});
|
|
16
|
+
const previous = await sql`select current_setting('statement_timeout') as value`.execute(db);
|
|
17
|
+
const restore = () => setTimeout(db, previous.rows[0].value);
|
|
18
|
+
await setTimeout(db, String(timeoutMs));
|
|
19
|
+
let result;
|
|
20
|
+
try {
|
|
21
|
+
result = await run(db);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
await restore().catch((restoreError) => {
|
|
24
|
+
if (restoreError.code !== "25P02") throw restoreError;
|
|
25
|
+
});
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
await restore();
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { withStatementTimeout };
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/query",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Kysely list, filter, count and aggregate queries over Protobase resources.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/PrototeamHQ/protobase.git",
|
|
9
|
+
"directory": "packages/query"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://docs.protobase.net",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.12.0"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"kysely": "^0.29.6",
|
|
29
|
+
"@protobase/schema": "0.1.1"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown"
|
|
33
|
+
}
|
|
6
34
|
}
|
package/README.md
DELETED