@protobase/server 0.17.0 → 0.18.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.
@@ -13,10 +13,9 @@ export type AdminOptions = {
13
13
  basePath?: string;
14
14
  /** Applied to every request's transaction with SET LOCAL. Default 15000. */
15
15
  statementTimeoutMs?: number;
16
- /** List shapes that would sequentially scan a table of more than `seqScanRows` rows (default 10000). Default mode `reject`. */
16
+ /** Reads that filter, sort or count values by a column no index serves. Default mode `reject`. */
17
17
  scanGuard?: {
18
18
  mode?: ScanGuardMode;
19
- seqScanRows?: number;
20
19
  };
21
20
  /** Runs inside the write transaction after each create, update and delete. */
22
21
  writeHooks?: PipelineHook[];
@@ -5,7 +5,7 @@ import { readScope, requestAccess, requireOperation } from "./request-access.js"
5
5
  import { readTransaction } from "./transactions.js";
6
6
  import { parseParams } from "./params.js";
7
7
  import { labelRequests, relatedLabels } from "./related-labels.js";
8
- import { facetCounts, histogram, series } from "@protobase/query";
8
+ import { facetCounts, histogram, series, withoutField } from "@protobase/query";
9
9
  import { sql } from "kysely";
10
10
  import { z } from "zod";
11
11
  //#region src/aggregate-service.ts
@@ -66,25 +66,36 @@ const facets = async (deps, entry, session, input) => {
66
66
  const params = parseParams(facetParams, input);
67
67
  const { model, filter: checked, scope } = await scoped(deps, entry, session, params.filter);
68
68
  const labels = params.label === void 0 ? [] : await labelRequests(deps, session, model, [params.field], `${params.field}.${params.label}`);
69
- return readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
70
- const counts = await facetCounts(trx, model, params.field, checked, {
71
- statementTimeoutMs: deps.statementTimeoutMs,
72
- ...params.limit !== void 0 && { limit: params.limit },
73
- scope
74
- });
75
- const names = (await relatedLabels(trx, labels, counts.map((facet) => ({ [params.field]: facet.value }))))[params.field] ?? {};
76
- const named = counts.map((facet) => {
77
- const label = facet.value === null ? void 0 : names[String(facet.value)];
78
- return label === void 0 ? facet : {
79
- ...facet,
80
- label
81
- };
82
- });
83
- return {
84
- field: params.field,
85
- facets: named
86
- };
69
+ const others = withoutField(checked, params.field);
70
+ const warning = await deps.guard(entry, {
71
+ ...others && { filter: others },
72
+ groupBy: params.field
73
+ }, {
74
+ model,
75
+ scope
87
76
  });
77
+ return {
78
+ body: await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
79
+ const counts = await facetCounts(trx, model, params.field, checked, {
80
+ statementTimeoutMs: deps.statementTimeoutMs,
81
+ ...params.limit !== void 0 && { limit: params.limit },
82
+ scope
83
+ });
84
+ const names = (await relatedLabels(trx, labels, counts.map((facet) => ({ [params.field]: facet.value }))))[params.field] ?? {};
85
+ const named = counts.map((facet) => {
86
+ const label = facet.value === null ? void 0 : names[String(facet.value)];
87
+ return label === void 0 ? facet : {
88
+ ...facet,
89
+ label
90
+ };
91
+ });
92
+ return {
93
+ field: params.field,
94
+ facets: named
95
+ };
96
+ }),
97
+ warning
98
+ };
88
99
  };
89
100
  const earliest = async (deps, access, fieldName, scope) => {
90
101
  const target = access.model.fields[fieldName];
@@ -69,8 +69,7 @@ const recordPhotos = async (deps, request, model, rows) => {
69
69
  const scope = readScope(access);
70
70
  const warning = await deps.guard(access.entry, {
71
71
  filter: filter.filter,
72
- ...sort && { sort },
73
- limit: cardPhotoLimit
72
+ ...sort && { sort }
74
73
  }, {
75
74
  model: access.model,
76
75
  scope
@@ -9,8 +9,9 @@ import { readTransaction } from "./transactions.js";
9
9
  import { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields } from "./params.js";
10
10
  import { labelRequests, relatedLabels } from "./related-labels.js";
11
11
  import { photosRequest, recordPhotos } from "./list-photos.js";
12
- import { estimateCount, exactCount, listQuery, seekToPosition } from "@protobase/query";
12
+ import { countUpTo, estimateCount, listQuery, seekToPosition } from "@protobase/query";
13
13
  //#region src/list-service.ts
14
+ const exactCountLimit = 1e4;
14
15
  /**
15
16
  * AIP-132 list, the `:search` variant and (with `jump`) `:seek`: both take the same parameters, from the query string or a JSON body.
16
17
  * Everything the caller names (filter, order_by, fields, search) is checked against the model as this caller sees it,
@@ -82,7 +83,7 @@ const listRecords = async (deps, entry, session, input, maxFilterLength, jump =
82
83
  etags: await Promise.all(rows.map((row, i) => etagOf(entry, page.rows[i], row))),
83
84
  permissions: deps.rowPermissions(entry.name) ? await rowPermissions(trx, access, rows) : void 0,
84
85
  estimate: await estimateCount(trx, queryModel, filter, scope),
85
- ...params.count === "exact" && { total: await exactCount(trx, queryModel, filter, scope) },
86
+ ...params.count === "exact" && { counted: await countUpTo(trx, queryModel, 10001, filter, scope) },
86
87
  ...params.labels !== void 0 && { labels: await relatedLabels(trx, labels, rows) }
87
88
  };
88
89
  });
@@ -99,7 +100,7 @@ const listRecords = async (deps, entry, session, input, maxFilterLength, jump =
99
100
  next_page_token: result.page.nextCursor ?? "",
100
101
  ...position !== void 0 && { prev_page_token: result.page.prevCursor ?? "" },
101
102
  total_size_estimate: result.estimate,
102
- ...result.total !== void 0 && { total_size: result.total },
103
+ ...result.counted !== void 0 && result.counted <= exactCountLimit && { total_size: result.counted },
103
104
  ...result.labels && { labels: result.labels },
104
105
  ...included && { photos: included.photos }
105
106
  }
@@ -48,7 +48,7 @@ const mapRecords = async (deps, entry, session, input) => {
48
48
  body: {
49
49
  items: body.items,
50
50
  buckets: [],
51
- total_size: body.total_size ?? found,
51
+ total_size: body.total_size ?? (found > 200 ? body.total_size_estimate : found),
52
52
  ...body.labels && { labels: body.labels },
53
53
  ...body.photos && { photos: body.photos }
54
54
  }
@@ -93,7 +93,7 @@ const parameters = {
93
93
  count: {
94
94
  name: "count",
95
95
  in: "query",
96
- description: "`exact` adds `total_size`, a real count(*)",
96
+ description: "`exact` adds `total_size`, a count of the matches when at most 10,000 match",
97
97
  schema: {
98
98
  type: "string",
99
99
  enum: ["exact"]
@@ -79,7 +79,7 @@ const page = (name, seek = false) => json({
79
79
  },
80
80
  total_size: {
81
81
  type: "integer",
82
- description: "Only with count=exact"
82
+ description: "Only with count=exact, and when at most 10,000 records match"
83
83
  },
84
84
  labels,
85
85
  photos
@@ -21,7 +21,10 @@ const collectionRoutes = (deps) => {
21
21
  const { body, warning } = await listRecords(deps, entry, session, query);
22
22
  return c.json(body, 200, warningHeader(warning));
23
23
  }
24
- if (action === "facets") return c.json(await facets(deps, entry, session, query));
24
+ if (action === "facets") {
25
+ const { body, warning } = await facets(deps, entry, session, query);
26
+ return c.json(body, 200, warningHeader(warning));
27
+ }
25
28
  if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
26
29
  if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
27
30
  if (action === "map") {
@@ -1,70 +1,43 @@
1
1
  import { HttpProblem } from "./problem.js";
2
- import { scopeConditions, tableId } from "./sql-parts.js";
3
- import { filterFields, filterShape } from "./filter-shape.js";
4
- import { buildListQuery, compileFilter, explainGuard } from "@protobase/query";
5
- import { sql } from "kysely";
2
+ import { tableIndexes, uncoveredColumns } from "@protobase/query";
6
3
  //#region src/scan-guard.ts
7
- const maxCached = 1e3;
8
- const filteredQuery = (db, entry, shape, { scope }) => {
9
- const { model } = entry;
10
- const conditions = [
11
- ...scopeConditions(scope.fullModel ?? model, scope.tenantValue),
12
- ...scope.rowFilter ? [compileFilter(model, scope.rowFilter)] : [],
13
- ...shape.filter ? [compileFilter(model, shape.filter)] : []
14
- ];
15
- let query = db.selectFrom(sql`${tableId(model)}`.as("t")).select(sql.lit(1).as("one"));
16
- for (const condition of conditions) query = query.where(condition);
17
- return query;
4
+ const reading = (column) => {
5
+ switch (column.use) {
6
+ case "filter": return `Filtering on "${column.field}"`;
7
+ case "access": return `The filter your role is limited by, on "${column.field}",`;
8
+ case "sort": return `Sorting by "${column.field}"`;
9
+ case "facet": return `Counting the values of "${column.field}"`;
10
+ }
18
11
  };
19
- const pagedQuery = async (db, shape, { model, scope }) => {
20
- const { query } = await buildListQuery(db, model, shape, scope);
21
- return query;
12
+ const describe = (column) => {
13
+ const kind = column.need === "btree" ? "an index" : column.need === "gin" ? "a GIN index" : "a pg_trgm index";
14
+ return `${reading(column)} needs ${kind} that serves the column "${column.column}". Add one, for example: ${column.suggestion}`;
22
15
  };
23
- const indexStatement = (entry, fields) => {
24
- const { schema, name } = entry.model.table;
25
- const table = schema ? `"${schema}"."${name}"` : `"${name}"`;
26
- const columns = [...new Set(fields.map((field) => field.column))];
27
- if (columns.length === 0) return "an index that covers the filter your role is limited by";
28
- return `create index on ${table} (${columns.map((column) => `"${column}"`).join(", ")})`;
29
- };
30
- /** Plans each list query shape once (EXPLAIN, never executed) and remembers the verdict. */
16
+ /**
17
+ * Checks that an index serves every column a read filters, sorts or groups by, from the table's indexes in the catalog
18
+ * (read once per table and kept for the process lifetime), so a read is never refused or allowed by how many rows match.
19
+ */
31
20
  const createScanGuard = (db, options = {}) => {
32
- const { mode = "reject", seqScanRows } = options;
33
- const verdicts = /* @__PURE__ */ new Map();
34
- const thresholds = {
35
- ...seqScanRows && { seqScanRows },
36
- sortRows: Number.MAX_SAFE_INTEGER
37
- };
38
- const judge = async (entry, shape, context) => {
39
- const filtered = shape.filter ? await explainGuard(db, filteredQuery(db, entry, shape, context), thresholds) : void 0;
40
- const paged = await explainGuard(db, await pagedQuery(db, shape, context), thresholds);
41
- const sortField = shape.sort?.[0] && entry.model.fields[shape.sort[0][0]];
42
- const suggestions = [...filtered && !filtered.ok ? [indexStatement(entry, filterFields(shape.filter))] : [], ...!paged.ok && sortField ? [indexStatement(entry, [sortField])] : []];
43
- return {
44
- findings: [...filtered?.findings ?? [], ...paged.findings],
45
- suggestion: suggestions[0] ?? indexStatement(entry, filterFields(shape.filter))
46
- };
47
- };
48
- const verdictFor = async (entry, shape, context) => {
49
- const key = `${entry.name}|${filterShape(context.scope.rowFilter)}|${filterShape(shape.filter)}|${(shape.sort ?? []).map(([name, direction]) => `${name}:${direction}`).join(",")}`;
50
- const known = verdicts.get(key);
21
+ const { mode = "reject" } = options;
22
+ const tables = /* @__PURE__ */ new Map();
23
+ const indexesOf = async (entry) => {
24
+ const known = tables.get(entry.name);
51
25
  if (known) return known;
52
- const verdict = await judge(entry, shape, context);
53
- if (verdicts.size >= maxCached) verdicts.delete(verdicts.keys().next().value);
54
- verdicts.set(key, verdict);
55
- return verdict;
26
+ const read = await tableIndexes(db, entry.model);
27
+ tables.set(entry.name, read);
28
+ return read;
56
29
  };
57
- /** Throws a 400 problem for an expensive shape in `reject` mode; in `warn` mode returns the warning text. */
58
- return async (entry, shape, context) => {
30
+ /** Throws a 400 problem for a column no index serves in `reject` mode; in `warn` mode returns the warning text. */
31
+ return async (entry, query, context) => {
59
32
  if (mode === "off") return void 0;
60
- const { findings, suggestion } = await verdictFor(entry, shape, context);
61
- const finding = findings[0];
62
- if (!finding) return void 0;
63
- const detail = `This query would sequentially scan "${finding.relation}" (about ${finding.rows} rows). Add an index, for example: ${suggestion}`;
33
+ const columns = uncoveredColumns(context.model, await indexesOf(entry), query, context.scope);
34
+ const first = columns[0];
35
+ if (!first) return void 0;
36
+ const detail = describe(first);
64
37
  if (mode === "warn") return detail;
65
38
  throw new HttpProblem(400, "expensive-query", "Bad Request", detail, {
66
- findings,
67
- suggestion
39
+ columns,
40
+ suggestion: first.suggestion
68
41
  });
69
42
  };
70
43
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.17.0",
34
- "@protobase/query": "0.17.0",
35
- "@protobase/schema": "0.17.0",
33
+ "@protobase/layout": "0.18.0",
34
+ "@protobase/query": "0.18.0",
35
+ "@protobase/schema": "0.18.0",
36
36
  "@react-email/render": "^2.1.0",
37
37
  "@scalar/hono-api-reference": "^0.12.10",
38
38
  "@sentry/core": "^11.7.0",
@@ -1,25 +0,0 @@
1
- //#region src/filter-shape.ts
2
- /** The filter with its values blanked out: queries that differ only in values share a shape. */
3
- const filterShape = (filter) => {
4
- if (!filter) return "";
5
- switch (filter.kind) {
6
- case "and":
7
- case "or": return `${filter.kind}(${filter.args.map(filterShape).join(",")})`;
8
- case "not": return `not(${filterShape(filter.arg)})`;
9
- case "compare": return `${filter.field.name}${filter.op}?`;
10
- case "search": return "search";
11
- default: return `${filter.kind}:${filter.field.name}`;
12
- }
13
- };
14
- const filterFields = (filter) => {
15
- if (!filter) return [];
16
- switch (filter.kind) {
17
- case "and":
18
- case "or": return filter.args.flatMap(filterFields);
19
- case "not": return filterFields(filter.arg);
20
- case "search": return [];
21
- default: return [filter.field];
22
- }
23
- };
24
- //#endregion
25
- export { filterFields, filterShape };