@protobase/server 0.17.0 → 0.19.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];
@@ -0,0 +1,5 @@
1
+ import { ResourceModel, ViewModel } from "@protobase/schema";
2
+ //#region src/check-view-queries.d.ts
3
+ /** Throws one error listing what every view asks for that its resource does not allow; views of unknown resources are left to the other checks. */
4
+ export declare const checkViewQueries: (models: ResourceModel[], views: ViewModel[]) => void;
5
+ //#endregion
@@ -0,0 +1,41 @@
1
+ //#region src/check-view-queries.ts
2
+ const widgetName = {
3
+ facets: "facets filter",
4
+ range: "range filter",
5
+ dateRange: "date range filter",
6
+ toggle: "toggle",
7
+ flags: "flags filter"
8
+ };
9
+ const filterableErrors = (model, names, what) => names.flatMap((name) => {
10
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
11
+ if (!field) return [`${what} names "${name}", which is not a field of ${model.name}`];
12
+ return field.filterable ? [] : [`${what} uses "${name}", which is not filterable, so the API refuses it: mark "${name}" .filterable()`];
13
+ });
14
+ const listSortErrors = (model, view) => (view.list?.sort ?? []).flatMap(([name]) => {
15
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
16
+ if (!field) return [`the list sorts by "${name}", which is not a field of ${model.name}`];
17
+ return field.sortable ? [] : [`the list sorts by "${name}", which is not sortable, so the API refuses it: mark "${name}" .sortable()`];
18
+ });
19
+ const searchErrors = (model, view) => (view.list?.search ?? []).filter((name) => !(model.search ?? []).includes(name)).map((name) => `the list searches "${name}", which ${model.name} does not search: add it to the resource's .search(...)`);
20
+ /**
21
+ * What a view asks the API for that its resource does not allow, which the API refuses only once a page asks: a filter
22
+ * widget or the chart on a field that is not `.filterable()`, a list sorted by one that is not `.sortable()`, and a list
23
+ * search on a field the resource does not search. The server's startup checks the rest of a view.
24
+ */
25
+ const viewQueryErrors = (view, model) => [
26
+ ...view.filters.flatMap((widget) => filterableErrors(model, widget.kind === "flags" ? widget.fields : [widget.field], `the ${widgetName[widget.kind]}`)),
27
+ ...view.chart ? filterableErrors(model, [view.chart.field], "the chart") : [],
28
+ ...listSortErrors(model, view),
29
+ ...searchErrors(model, view)
30
+ ];
31
+ /** Throws one error listing what every view asks for that its resource does not allow; views of unknown resources are left to the other checks. */
32
+ const checkViewQueries = (models, views) => {
33
+ const byName = new Map(models.map((model) => [model.name, model]));
34
+ const problems = views.flatMap((view) => {
35
+ const model = byName.get(view.resource);
36
+ return model ? viewQueryErrors(view, model).map((problem) => `View "${view.resource}": ${problem}`) : [];
37
+ });
38
+ if (problems.length > 0) throw new Error(`The views ask for what their resources do not allow:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
39
+ };
40
+ //#endregion
41
+ export { checkViewQueries, viewQueryErrors };
@@ -21,6 +21,7 @@ const sensitiveErrors = (view, model) => {
21
21
  ["the chart", view.chart ? [view.chart.field] : []],
22
22
  ["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
23
23
  ["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
24
+ ["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
24
25
  ["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
25
26
  ["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : linkFields(action.run))]
26
27
  ].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
@@ -55,7 +56,11 @@ const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name,
55
56
  });
56
57
  const unknownFieldErrors = (view, model) => {
57
58
  const unknown = (names, what) => names.filter((name) => !Object.hasOwn(model.fields, name)).map((name) => `${what} names field "${name}", which ${model.name} does not have`);
58
- return [...(view.list?.displays ?? []).flatMap((display) => unknown(displayFields(display), `display "${display.name}"`)), ...view.layout.flatMap((item) => item.kind === "custom" ? unknown(fieldRefsIn(item.props), `custom section ${item.component}`) : [])];
59
+ return [
60
+ ...(view.list?.displays ?? []).flatMap((display) => unknown(displayFields(display), `display "${display.name}"`)),
61
+ ...view.layout.flatMap((item) => item.kind === "custom" ? unknown(fieldRefsIn(item.props), `custom section ${item.component}`) : []),
62
+ ...view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? unknown([item.image], `gallery "${item.title}"`) : [])
63
+ ];
59
64
  };
60
65
  const widgetErrors = (view, model, fieldTypes) => Object.entries(view.fields).flatMap(([name, hints]) => {
61
66
  const of = hints.widget && fieldTypes[hints.widget.name];
@@ -71,6 +76,10 @@ const sharedPhotosErrors = (view) => {
71
76
  const same = (a, b) => a.resource === b.resource && a.field === b.field && a.image === b.image && a.sort === b.sort;
72
77
  return shown.filter(({ photos }) => !same(photos, shown[0].photos)).map(({ name }) => `display "${name}" photos: the list's records load with one set of photos, so name those of display "${shown[0].name}"`);
73
78
  };
79
+ const galleryErrors = (item, resource, model, models) => {
80
+ const image = item.image === void 0 ? void 0 : model.fields[item.image];
81
+ return [...image && image.type !== "file" ? [`"${item.image}" is not a file field`] : [], ...item.photos ? cardPhotosErrors(item.photos, resource, models).map((error) => `photos: ${error}`) : []];
82
+ };
74
83
  const listOfErrors = (model, name, models) => {
75
84
  const target = models[model.fields[name]?.relation?.resource ?? ""];
76
85
  if (!target) return [`"${name}" points at no resource of the app`];
@@ -80,7 +89,7 @@ const listOfErrors = (model, name, models) => {
80
89
  * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
81
90
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
82
91
  * `fieldTypes` (each with the storage types it shows) that does not show it,
83
- * has a related section, a cards display's photos or favorite button, a `listOf`, a "Save search" button or a
92
+ * has a related section, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
84
93
  * `searchesOf` that do not fit the resources, cards
85
94
  * displays of one list that name different photos, a map that cannot show the
86
95
  * record's position or a map display that cannot find the records in its view, or a link to an address the app does not have.
@@ -92,7 +101,11 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
92
101
  const model = models[view.resource];
93
102
  if (!model) return [];
94
103
  const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
95
- const photos = [...(view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? cardPhotosErrors(display.photos, view.resource, models).map((error) => `display "${display.name}" photos: ${error}`) : []), ...sharedPhotosErrors(view)];
104
+ const photos = [
105
+ ...(view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? cardPhotosErrors(display.photos, view.resource, models).map((error) => `display "${display.name}" photos: ${error}`) : []),
106
+ ...sharedPhotosErrors(view),
107
+ ...view.layout.flatMap((item) => item.kind === "gallery" ? galleryErrors(item, view.resource, model, models).map((error) => `gallery "${item.title}": ${error}`) : [])
108
+ ];
96
109
  const favorites = (view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.favorite ? [...cardFavoriteErrors(display.favorite, view.resource, models), ...favoriteOwnerErrors(display.favorite, models)].map((error) => `display "${display.name}" favorite: ${error}`) : []);
97
110
  const listOf = view.listOf === void 0 ? [] : listOfErrors(model, view.listOf, models).map((error) => `listOf: ${error}`);
98
111
  const searches = [...view.saveSearch ? saveSearchErrors(view.saveSearch, view.resource, models, views).map((error) => `saveSearch: ${error}`) : [], ...view.searchesOf ? searchesOfErrors(view.searchesOf, view.resource, models).map((error) => `searchesOf: ${error}`) : []];
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ import { MapOptions, mapStyleUrl, mapStyleVariable } from "./maps/map-settings.j
37
37
  import { RuntimeOptions, runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
38
38
  import { AdminOptions } from "./admin-options.js";
39
39
  import { CreateAdminInput, createAdmin } from "./create-admin.js";
40
+ import { checkViewQueries } from "./check-view-queries.js";
40
41
  import { ProjectConfig, defineConfig, mergeConfig } from "./project-config.js";
41
42
  import { consoleAuditQueue } from "./audit/console-queue.js";
42
43
  import { JwtOptions, jwtAuthenticator } from "./auth/jwt.js";
@@ -82,4 +83,4 @@ import { FatalJobError } from "./jobs/run-job.js";
82
83
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
83
84
  import { jobsDrainApp } from "./jobs/drain-route.js";
84
85
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
85
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapOptions, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
86
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapOptions, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ import { checkSourceNames, sourceOptions, sourcesFor, sourcesVariable } from "./
42
42
  import { renderVariant, thumbHash } from "./files/images.js";
43
43
  import { createAdmin } from "./create-admin.js";
44
44
  import { configExports } from "./resource-source.js";
45
+ import { checkViewQueries } from "./check-view-queries.js";
45
46
  import { defineConfig, mergeConfig } from "./project-config.js";
46
47
  import { jwtAuthenticator } from "./auth/jwt.js";
47
48
  import { withTrustedApps } from "./better-auth/app-tokens.js";
@@ -63,4 +64,4 @@ import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/p
63
64
  import { defineEmail } from "./mail/define-email.js";
64
65
  import { startJobs } from "./jobs/runtime.js";
65
66
  import { jobsDrainApp } from "./jobs/drain-route.js";
66
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
67
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
@@ -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
  };
@@ -14,6 +14,17 @@ const pruneRelated = (item, resource, models) => {
14
14
  } : rest;
15
15
  return relatedErrors(narrowed, resource, models).length === 0 ? narrowed : void 0;
16
16
  };
17
+ const pruneGallery = (item, resource, model, models) => {
18
+ const image = item.image !== void 0 && Object.hasOwn(model.fields, item.image) ? item.image : void 0;
19
+ const photos = item.photos && cardPhotosErrors(item.photos, resource, models).length === 0 ? item.photos : void 0;
20
+ if (image === void 0 && photos === void 0) return void 0;
21
+ return {
22
+ kind: "gallery",
23
+ title: item.title,
24
+ ...image !== void 0 && { image },
25
+ ...photos && { photos }
26
+ };
27
+ };
17
28
  const keepDefault = ({ default: opens, displays, ...list }) => {
18
29
  const shown = displays && displays.length > 0 ? displays : void 0;
19
30
  const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
@@ -97,11 +108,12 @@ const widgetShape = shapes.oneOf({
97
108
  label: shapes.data
98
109
  }, { requires: ["fields"] })
99
110
  });
100
- const layoutShape = (resource, models) => shapes.oneOf({
111
+ const layoutShape = (resource, model, models) => shapes.oneOf({
101
112
  section: shapes.object({
102
113
  kind: shapes.data,
103
114
  title: shapes.data,
104
115
  help: shapes.data,
116
+ helpWhen: shapes.data,
105
117
  fields: shapes.list(shapes.field, { nonEmpty: true })
106
118
  }, { requires: ["fields"] }),
107
119
  sidebar: shapes.object({
@@ -123,7 +135,8 @@ const layoutShape = (resource, models) => shapes.oneOf({
123
135
  longitude: shapes.field,
124
136
  zoom: shapes.data,
125
137
  sidebar: shapes.data
126
- }, { requires: ["latitude", "longitude"] })
138
+ }, { requires: ["latitude", "longitude"] }),
139
+ gallery: shapes.map((item) => pruneGallery(item, resource, model, models))
127
140
  });
128
141
  const linkShape = (model, models) => shapes.map((run) => {
129
142
  return ("to" in run ? addressErrors(run.to, {
@@ -166,12 +179,14 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
166
179
  fields: shapes.keys(),
167
180
  list: listShape(resource, models, prompted),
168
181
  filters: shapes.list(widgetShape),
169
- layout: shapes.list(layoutShape(resource, models)),
182
+ layout: shapes.list(layoutShape(resource, model, models)),
170
183
  chart: shapes.object({
171
184
  field: shapes.field,
172
185
  range: shapes.data,
173
186
  granularity: shapes.data
174
187
  }, { requires: ["field"] }),
188
+ recordMode: shapes.data,
189
+ helpWhen: shapes.data,
175
190
  saveFeedback: shapes.data,
176
191
  actions: shapes.list(actionShape(model, models)),
177
192
  listOf: shapes.field,
@@ -181,7 +196,7 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
181
196
  });
182
197
  /**
183
198
  * The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
184
- * field hints, filter widgets, record sections, maps and custom components' props, the chart, title and search result, the
199
+ * field hints, filter widgets, record sections, maps, galleries and custom components' props, the chart, title and search result, the
185
200
  * actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections
186
201
  * and links are checked against `models`, the caller's view of every resource, and so are `searchesOf`, cards' favorite
187
202
  * buttons and the "Save search" button; the buttons stay for a visitor when their resource is one of `prompted`, those
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.17.0",
3
+ "version": "0.19.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.19.0",
34
+ "@protobase/query": "0.19.0",
35
+ "@protobase/schema": "0.19.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 };