@protobase/server 0.14.4 → 0.14.5

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.
@@ -1,6 +1,7 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
2
  import { cardPhotosErrors } from "./view-card-photos.js";
3
3
  import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js";
4
+ import { saveSearchErrors, searchesOfErrors } from "./view-saved-search.js";
4
5
  import { mapDisplayErrors, mapErrors } from "./view-map.js";
5
6
  import { addressErrors, addressPlaceholders, appHrefError, displayFields, fieldRefsIn, hrefPlaceholders } from "@protobase/schema/internal";
6
7
  //#region src/check-views.ts
@@ -75,7 +76,8 @@ const listOfErrors = (model, name, models) => {
75
76
  * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
76
77
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
77
78
  * `fieldTypes` (each with the storage types it shows) that does not show it,
78
- * has a related section, a cards display's photos or favorite button or a `listOf` that do not fit the resources, cards
79
+ * has a related section, a cards display's photos or favorite button, a `listOf`, a "Save search" button or a
80
+ * `searchesOf` that do not fit the resources, cards
79
81
  * displays of one list that name different photos, a map that cannot show the
80
82
  * 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.
81
83
  */
@@ -89,6 +91,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
89
91
  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)];
90
92
  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}`) : []);
91
93
  const listOf = view.listOf === void 0 ? [] : listOfErrors(model, view.listOf, models).map((error) => `listOf: ${error}`);
94
+ 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}`) : []];
92
95
  const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
93
96
  return [
94
97
  ...sensitiveErrors(view, model),
@@ -100,6 +103,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
100
103
  ...photos,
101
104
  ...favorites,
102
105
  ...listOf,
106
+ ...searches,
103
107
  ...maps,
104
108
  ...linkErrors(view, model, models, pageNames)
105
109
  ].map((problem) => `View "${view.resource}": ${problem}`);
package/dist/index.d.ts CHANGED
@@ -78,7 +78,8 @@ import { FileRef, formatUri, variantPath } from "./files/uri.js";
78
78
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
79
79
  import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
80
80
  import { FatalJobError } from "./jobs/run-job.js";
81
+ import { EmailComponent, EmailOptions, EmailTemplate, RenderedEmail, defineEmail } from "./mail/define-email.js";
81
82
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
82
83
  import { jobsDrainApp } from "./jobs/drain-route.js";
83
84
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
84
- 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 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 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, 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 };
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 };
package/dist/index.js CHANGED
@@ -60,6 +60,7 @@ import { requireEnv } from "./functions/require-env.js";
60
60
  import { storeFile } from "./files/store-file.js";
61
61
  import { createFileCleanup } from "./files/file-cleanup.js";
62
62
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
63
+ import { defineEmail } from "./mail/define-email.js";
63
64
  import { startJobs } from "./jobs/runtime.js";
64
65
  import { jobsDrainApp } from "./jobs/drain-route.js";
65
- 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, 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 };
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 };
@@ -14,7 +14,7 @@ export type JobContext = {
14
14
  };
15
15
  /** Aborted when the job's timeout passes, or when the worker shuts down and its grace period ends. */
16
16
  signal: AbortSignal;
17
- /** Sends plain-text mail through the app's SMTP server (`PROTOBASE_SMTP_URL`, `PROTOBASE_MAIL_FROM`); `undefined` when the app has no mail. */
17
+ /** Sends mail through the app's SMTP server (`PROTOBASE_SMTP_URL`, `PROTOBASE_MAIL_FROM`); `undefined` when the app has no mail. */
18
18
  mail?: Mailer;
19
19
  };
20
20
  export type JobOptions<Payload> = {
@@ -0,0 +1,39 @@
1
+ import { Mailer } from "./smtp-mailer.js";
2
+ //#region src/mail/define-email.d.ts
3
+ export type EmailOptions<Data extends object> = {
4
+ /** Names the email in errors, such as `welcome` or `listing-alert`. */
5
+ name: string;
6
+ /** The subject line, or how to make it from the email's data. */
7
+ subject: string | ((data: Data) => string);
8
+ /**
9
+ * The message as a React component of the email's data, such as one built from React Email's components (`react-email`).
10
+ * It is rendered to the HTML part, and the plain-text part is made from that HTML.
11
+ */
12
+ component: EmailComponent<Data>;
13
+ };
14
+ /** A function component of the email's data, as JSX writes one; typed here so the server's own types need no React. */
15
+ export type EmailComponent<Data> = (props: Data) => unknown;
16
+ /** An email as `render` gives it: the subject and both parts of the body. */
17
+ export type RenderedEmail = {
18
+ subject: string;
19
+ html: string;
20
+ text: string;
21
+ };
22
+ export type EmailTemplate<Data extends object> = {
23
+ readonly name: string;
24
+ readonly options: EmailOptions<Data>;
25
+ /** Renders the email for `data`, without sending it: for a preview, a test, or a mailer of your own. */
26
+ render(data: Data): Promise<RenderedEmail>;
27
+ /**
28
+ * Renders the email for `data` and sends it to `to`: through `mailer` when given, such as a job's `context.mail`,
29
+ * otherwise through the app's SMTP server (`PROTOBASE_SMTP_URL`, `PROTOBASE_MAIL_FROM`). Rejects when mail is off
30
+ * or the server refuses the message.
31
+ */
32
+ send(to: string, data: Data, mailer?: Mailer): Promise<void>;
33
+ };
34
+ /**
35
+ * An email the app sends, with typed data: its subject and a React component for its body. Protobase renders it to
36
+ * HTML with React Email's renderer, makes the plain-text part from that, and sends both through the app's mail.
37
+ */
38
+ export declare const defineEmail: <Data extends object>(options: EmailOptions<Data>) => EmailTemplate<Data>;
39
+ //#endregion
@@ -0,0 +1,42 @@
1
+ import { mailFromVariable, smtpUrlVariable } from "./mail-settings.js";
2
+ import { platformMailer } from "./platform-mailer.js";
3
+ //#region src/mail/define-email.ts
4
+ /** React and React Email's renderer, loaded on the first email rendered, so an app that sends none never loads them. */
5
+ const loadRenderer = async () => {
6
+ const [{ createElement }, { render, toPlainText }] = await Promise.all([import("react"), import("@react-email/render")]);
7
+ return {
8
+ createElement,
9
+ render,
10
+ toPlainText
11
+ };
12
+ };
13
+ /**
14
+ * An email the app sends, with typed data: its subject and a React component for its body. Protobase renders it to
15
+ * HTML with React Email's renderer, makes the plain-text part from that, and sends both through the app's mail.
16
+ */
17
+ const defineEmail = (options) => {
18
+ const render = async (data) => {
19
+ const renderer = await loadRenderer();
20
+ const html = await renderer.render(renderer.createElement(options.component, data));
21
+ return {
22
+ subject: typeof options.subject === "function" ? options.subject(data) : options.subject,
23
+ html,
24
+ text: renderer.toPlainText(html)
25
+ };
26
+ };
27
+ const send = async (to, data, mailer = platformMailer()) => {
28
+ if (!mailer) throw new Error(`Email "${options.name}" cannot be sent: mail is off; set ${smtpUrlVariable} and ${mailFromVariable}`);
29
+ await mailer.send({
30
+ to,
31
+ ...await render(data)
32
+ });
33
+ };
34
+ return {
35
+ name: options.name,
36
+ options,
37
+ render,
38
+ send
39
+ };
40
+ };
41
+ //#endregion
42
+ export { defineEmail };
@@ -2,7 +2,10 @@
2
2
  export type MailMessage = {
3
3
  to: string;
4
4
  subject: string;
5
+ /** The plain-text body, which clients that show no HTML show instead. */
5
6
  text: string;
7
+ /** The HTML body, sent beside `text` as the alternative clients show when they can. */
8
+ html?: string;
6
9
  };
7
10
  /** Sends one message; rejects when the server refuses it. */
8
11
  export type Mailer = {
@@ -1,6 +1,6 @@
1
1
  //#region src/mail/smtp-mailer.ts
2
2
  /**
3
- * Plain-text mail over SMTP with Nodemailer: the most used Node SMTP client, maintained, with no dependencies of its
3
+ * Mail over SMTP with Nodemailer: the most used Node SMTP client, maintained, with no dependencies of its
4
4
  * own. It is loaded on the first message, so `@protobase/server` itself stays free of Node's network modules.
5
5
  *
6
6
  * The client greets the server (EHLO) with the sender's domain, the app's own mail name, instead of what the container
@@ -1,6 +1,7 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
2
  import { cardPhotosErrors } from "./view-card-photos.js";
3
3
  import { cardFavoriteErrors } from "./view-card-favorite.js";
4
+ import { searchesOfFor } from "./view-saved-search.js";
4
5
  import { resolveFieldPath } from "@protobase/layout";
5
6
  import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
6
7
  //#region src/view-access.ts
@@ -169,14 +170,17 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
169
170
  saveFeedback: shapes.data,
170
171
  actions: shapes.list(actionShape(model, models)),
171
172
  listOf: shapes.field,
172
- signIn: shapes.data
173
+ signIn: shapes.data,
174
+ saveSearch: shapes.map((search) => prompted.has(search.resource) || models[search.resource] ? search : void 0),
175
+ searchesOf: shapes.map((searches) => searchesOfFor(searches, resource, models))
173
176
  });
174
177
  /**
175
178
  * The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
176
179
  * field hints, filter widgets, record sections, maps and custom components' props, the chart, title and search result, the
177
180
  * actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections
178
- * and links are checked against `models`, the caller's view of every resource, and so are cards' favorite buttons,
179
- * which stay for a visitor when their resource is one of `prompted`, those that ask the visitor to sign in.
181
+ * and links are checked against `models`, the caller's view of every resource, and so are `searchesOf`, cards' favorite
182
+ * buttons and the "Save search" button; the buttons stay for a visitor when their resource is one of `prompted`, those
183
+ * that ask the visitor to sign in.
180
184
  */
181
185
  const pruneView = (view, model, models, prompted = /* @__PURE__ */ new Set()) => {
182
186
  const pruned = pruneByShape(view, viewShape(view.resource, model, models, prompted), (name) => Object.hasOwn(model.fields, name));
@@ -0,0 +1,49 @@
1
+ //#region src/view-saved-search.ts
2
+ const writable = (model, role, name) => name === void 0 || !model.fields[name]?.readOnly ? [] : [`${role} "${name}" is read-only, so a search cannot be saved or changed`];
3
+ /**
4
+ * What is wrong with a view's `searchesOf` on `resource`, against the app's models: the searched resource is there and
5
+ * its `unseen.field` a filterable timestamp or date, the searches are each user's own, and the fields a save or a control
6
+ * writes are writable.
7
+ */
8
+ const searchesOfErrors = (searches, resource, models) => {
9
+ const own = models[resource];
10
+ const target = models[searches.resource];
11
+ if (!own || !target) return [`unknown resource "${own ? searches.resource : resource}"`];
12
+ const seen = searches.unseen && Object.hasOwn(target.fields, searches.unseen.field) ? target.fields[searches.unseen.field] : void 0;
13
+ return [
14
+ ...own.owner === void 0 ? [`${resource} has no owner, so its searches are not each user's own`] : [],
15
+ ...writable(own, "filter", searches.filter),
16
+ ...writable(own, "name", searches.name),
17
+ ...writable(own, "unseen.seenAt", searches.unseen?.seenAt),
18
+ ...(searches.controls ?? []).flatMap((name) => writable(own, "control", name)),
19
+ ...searches.unseen && !seen ? [`unseen.field: unknown field "${searches.unseen.field}" on ${target.name}`] : [],
20
+ ...seen && !(seen.filterable && (seen.type === "timestamp" || seen.type === "date")) ? [`unseen.field "${seen.name}" on ${target.name} is not a filterable timestamp or date`] : []
21
+ ];
22
+ };
23
+ /** What is wrong with a list page's "Save search" button on `resource`: a view of the searches' resource lists searches of it. */
24
+ const saveSearchErrors = (search, resource, models, views) => {
25
+ if (!models[search.resource]) return [`unknown resource "${search.resource}"`];
26
+ return views.some((view) => view.resource === search.resource && view.searchesOf?.resource === resource) ? [] : [`no view of ${search.resource} has searchesOf("${resource}"), so a saved search would be listed nowhere`];
27
+ };
28
+ /**
29
+ * The view's `searchesOf` as one caller may use it, against their models: gone when they cannot list the searched
30
+ * resource or read the filter, the name and the controls narrowed to those they can write, and `unseen` kept while they
31
+ * can write `seenAt` and filter by `field`.
32
+ */
33
+ const searchesOfFor = (searches, resource, models) => {
34
+ const own = models[resource];
35
+ const target = models[searches.resource];
36
+ if (!own || !target || !Object.hasOwn(own.fields, searches.filter)) return void 0;
37
+ const canWrite = (name) => Object.hasOwn(own.fields, name) && !own.fields[name].readOnly;
38
+ const { name, controls, unseen, ...rest } = searches;
39
+ const kept = controls?.filter(canWrite);
40
+ const seenKept = unseen && canWrite(unseen.seenAt) && target.fields[unseen.field]?.filterable ? unseen : void 0;
41
+ return {
42
+ ...rest,
43
+ ...name !== void 0 && canWrite(name) && { name },
44
+ ...kept && kept.length > 0 && { controls: kept },
45
+ ...seenKept && { unseen: seenKept }
46
+ };
47
+ };
48
+ //#endregion
49
+ export { saveSearchErrors, searchesOfErrors, searchesOfFor };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
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,10 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.14.4",
34
- "@protobase/query": "0.14.4",
35
- "@protobase/schema": "0.14.4",
33
+ "@protobase/layout": "0.14.5",
34
+ "@protobase/query": "0.14.5",
35
+ "@protobase/schema": "0.14.5",
36
+ "@react-email/render": "^2.1.0",
36
37
  "@scalar/hono-api-reference": "^0.12.10",
37
38
  "@sentry/core": "^11.7.0",
38
39
  "@simplewebauthn/server": "^13.3.1",
@@ -45,6 +46,8 @@
45
46
  "kysely": "^0.29.6",
46
47
  "nodemailer": "^10.0.16",
47
48
  "pg-boss": "12.37.1",
49
+ "react": "^19.3.0",
50
+ "react-dom": "^19.3.0",
48
51
  "thumbhash": "^0.1.1",
49
52
  "zod": "^4.6.5"
50
53
  },