@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.
- package/dist/admin-options.d.ts +1 -2
- package/dist/aggregate-service.js +30 -19
- package/dist/list-photos.js +1 -2
- package/dist/list-service.js +4 -3
- package/dist/map-service.js +1 -1
- package/dist/openapi/components.js +1 -1
- package/dist/openapi/resource-paths.js +1 -1
- package/dist/routes/collection.js +4 -1
- package/dist/scan-guard.js +30 -57
- package/package.json +4 -4
- package/dist/filter-shape.js +0 -25
package/dist/admin-options.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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];
|
package/dist/list-photos.js
CHANGED
|
@@ -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
|
package/dist/list-service.js
CHANGED
|
@@ -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 {
|
|
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" && {
|
|
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.
|
|
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
|
}
|
package/dist/map-service.js
CHANGED
|
@@ -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
|
|
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"]
|
|
@@ -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")
|
|
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") {
|
package/dist/scan-guard.js
CHANGED
|
@@ -1,70 +1,43 @@
|
|
|
1
1
|
import { HttpProblem } from "./problem.js";
|
|
2
|
-
import {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
20
|
-
const
|
|
21
|
-
return
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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"
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
58
|
-
return async (entry,
|
|
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
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
63
|
-
const detail =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@protobase/query": "0.
|
|
35
|
-
"@protobase/schema": "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",
|
package/dist/filter-shape.js
DELETED
|
@@ -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 };
|