@protobase/server 0.14.4 → 0.14.6

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.
@@ -0,0 +1,14 @@
1
+ //#region src/changes/event-code-error.ts
2
+ /**
3
+ * An error the app's code threw while a change was dispatched: an event's `match` or filter, a subscription's `when`, or
4
+ * an access rule of the roles it delivers as. Dispatch keeps the change as failed and moves on; any other error, such
5
+ * as the database's, fails the whole dispatch, which is retried.
6
+ */
7
+ var EventCodeError = class extends Error {
8
+ name = "EventCodeError";
9
+ constructor(what, error) {
10
+ super(`${what} threw: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
11
+ }
12
+ };
13
+ //#endregion
14
+ export { EventCodeError };
@@ -1,3 +1,4 @@
1
+ import { EventCodeError } from "./event-code-error.js";
1
2
  import { evaluateFilter } from "@protobase/schema";
2
3
  import { checkEventFilter } from "@protobase/schema/internal";
3
4
  //#region src/changes/match-events.ts
@@ -39,7 +40,13 @@ const eventTest = (model, event, match) => {
39
40
  if (!match) throw new Error(`Resource "${model.name}": event "${event.name}" has no match function`);
40
41
  tests.push((change) => match(contextOf(change)));
41
42
  }
42
- return (change) => tests.every((test) => test(change));
43
+ return (change) => {
44
+ try {
45
+ return tests.every((test) => test(change));
46
+ } catch (error) {
47
+ throw new EventCodeError(`Event ${event.name} on ${change.resource} ${change.key}`, error);
48
+ }
49
+ };
43
50
  };
44
51
  /**
45
52
  * The names of the resource's events a change raises, in declaration order. The filters are checked once, here; `matches`
@@ -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 };
@@ -1,3 +1,4 @@
1
+ import { EventCodeError } from "../changes/event-code-error.js";
1
2
  import { eventPatternMatches } from "./subscription.js";
2
3
  import { evaluateFilter } from "@protobase/schema";
3
4
  import { checkEventFilter } from "@protobase/schema/internal";
@@ -48,7 +49,12 @@ const subscriptionRouter = (subscriptions, models) => {
48
49
  if (options.enabled === false) return false;
49
50
  if (options.tenant !== void 0 && String(options.tenant) !== String(change.tenant)) return false;
50
51
  const filter = filters.get(`${subscription.name}\0${change.resource}`);
51
- return !filter || evaluateFilter(filter, change.after ?? change.before) === true;
52
+ if (!filter) return true;
53
+ try {
54
+ return evaluateFilter(filter, change.after ?? change.before) === true;
55
+ } catch (error) {
56
+ throw new EventCodeError(`The when of subscription "${subscription.name}" on ${change.resource} ${change.key}`, error);
57
+ }
52
58
  };
53
59
  return (change, events) => subscriptions.flatMap((subscription) => {
54
60
  if (!passes(subscription, change)) return [];
@@ -1,4 +1,5 @@
1
1
  import { requestAccess } from "../request-access.js";
2
+ import { EventCodeError } from "../changes/event-code-error.js";
2
3
  import { checkRecord, evaluateFilter } from "@protobase/schema";
3
4
  //#region src/webhooks/subscriber-view.ts
4
5
  /**
@@ -64,8 +65,14 @@ const subscriberEvents = (deps) => {
64
65
  if (!entry) throw new Error(`Not implemented: a change of "${change.resource}", which is no resource of the app`);
65
66
  const cacheKey = `${subscription.name}\0${change.resource}`;
66
67
  if (!accesses.has(cacheKey)) accesses.set(cacheKey, requestAccess(deps, entry, session));
67
- const access = await accesses.get(cacheKey);
68
- if (!await mayRead(access, change.after ?? change.before)) return void 0;
68
+ const readable = async () => {
69
+ const access = await accesses.get(cacheKey);
70
+ return await mayRead(access, change.after ?? change.before) ? access : void 0;
71
+ };
72
+ const access = await readable().catch((error) => {
73
+ throw new EventCodeError(`The access check of subscription "${subscription.name}" on ${change.resource} ${change.key}`, error);
74
+ });
75
+ if (!access) return void 0;
69
76
  return subscriptionEvent(change, event, {
70
77
  fields: Object.keys(access.readable.fields),
71
78
  thin
@@ -1,5 +1,6 @@
1
1
  import { dispatchJobName } from "../changes/capture-sql.js";
2
2
  import { coalesce } from "../changes/coalesce.js";
3
+ import { EventCodeError } from "../changes/event-code-error.js";
3
4
  import { eventMatcher } from "../changes/match-events.js";
4
5
  import { deleteOutbox, readOutbox } from "../changes/outbox.js";
5
6
  import { resourceChange } from "../changes/resource-change.js";
@@ -41,6 +42,9 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
41
42
  * time: it turns the committed outbox rows into events, makes the first try of every unordered delivery itself,
42
43
  * queues the failures and the ordered deliveries, and deletes the rows in that same transaction. A process that dies
43
44
  * before that commits leaves the rows, and the job's retry delivers them again, under the same `webhook-id`.
45
+ * - `webhooks.dispatch-change`, one change whose app code threw in dispatch: an event's `match` or filter, a
46
+ * subscription's `when`, or an access rule of its roles. Kept as failed, so dispatch moves on to later changes; a
47
+ * retry after the fix queues the change's deliveries. Any other error fails the whole dispatch, which is retried.
44
48
  * - `webhooks.deliver`, one try of an unordered delivery; a failure queues the next try on the Standard Webhooks
45
49
  * schedule, or Retry-After, and the last one is kept as failed.
46
50
  * - `webhooks.deliver-ordered`, the ordered deliveries of one dispatch for one subscription and record, retried with
@@ -138,20 +142,54 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
138
142
  }
139
143
  }
140
144
  });
141
- const deliveriesOf = async (changes, db) => {
145
+ const changeDeliveries = async (db) => {
142
146
  const route = subscriptionRouter(await current(db), models);
143
147
  const eventOf = subscriberEvents(deps);
144
- const deliveries = [];
145
- for (const change of changes) for (const { subscription, event } of route(change, matchers.get(change.resource)(change))) {
146
- const shaped = await eventOf(subscription, change, event);
147
- if (shaped) deliveries.push({
148
- subscription,
149
- event: shaped,
150
- change
151
- });
148
+ return async (change) => {
149
+ const deliveries = [];
150
+ for (const { subscription, event } of route(change, matchers.get(change.resource)(change))) {
151
+ const shaped = await eventOf(subscription, change, event);
152
+ if (shaped) deliveries.push({
153
+ subscription,
154
+ event: shaped,
155
+ change
156
+ });
157
+ }
158
+ return deliveries;
159
+ };
160
+ };
161
+ const queueOrdered = async (db, deliveries) => {
162
+ const groups = /* @__PURE__ */ new Map();
163
+ for (const delivery of deliveries.filter(({ subscription }) => subscription.options.ordered)) {
164
+ const group = deliveryGroup(delivery);
165
+ const payload = groups.get(group) ?? {
166
+ subscription: delivery.subscription.name,
167
+ events: []
168
+ };
169
+ payload.events.push(delivery.event);
170
+ groups.set(group, payload);
152
171
  }
153
- return deliveries;
172
+ for (const [group, payload] of groups) await deliverOrdered.enqueue(db, payload, { group });
154
173
  };
174
+ const dispatchChange = defineJob({
175
+ name: "webhooks.dispatch-change",
176
+ maxAttempts: 1,
177
+ timeout: "5 minutes",
178
+ run: async ({ change }, { db }) => {
179
+ const deliveries = await (await changeDeliveries(db))(change);
180
+ await db.transaction().execute(async (trx) => {
181
+ for (const { subscription, event } of deliveries.filter(({ subscription }) => !subscription.options.ordered)) {
182
+ const payload = {
183
+ subscription: subscription.name,
184
+ event,
185
+ attempt: 1
186
+ };
187
+ await deliver.enqueue(trx, payload, { key: deliveryKey(payload) });
188
+ }
189
+ await queueOrdered(trx, deliveries);
190
+ });
191
+ }
192
+ });
155
193
  const dispatchBatch = async (db, signal) => {
156
194
  const rows = await readOutbox(db, schema, models, batchSize);
157
195
  if (rows.length === 0) return 0;
@@ -159,7 +197,18 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
159
197
  const entry = deps.registry.find(change.resource);
160
198
  return entry ? [resourceChange(entry.model, change)] : [];
161
199
  });
162
- const deliveries = await deliveriesOf(changes, db);
200
+ const deliveriesOf = await changeDeliveries(db);
201
+ const deliveries = [];
202
+ const failures = [];
203
+ for (const change of changes) try {
204
+ deliveries.push(...await deliveriesOf(change));
205
+ } catch (error) {
206
+ if (!(error instanceof EventCodeError)) throw error;
207
+ failures.push({
208
+ change,
209
+ error
210
+ });
211
+ }
163
212
  const now = deliveries.filter(({ subscription }) => !subscription.options.ordered);
164
213
  const results = await mapLimit(now, parallelAttempts, ({ subscription, event }) => attempt(subscription, event, db, 1, signal));
165
214
  await db.transaction().execute(async (trx) => {
@@ -179,20 +228,12 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
179
228
  delay
180
229
  });
181
230
  }
182
- const groups = /* @__PURE__ */ new Map();
183
- for (const delivery of deliveries.filter(({ subscription }) => subscription.options.ordered)) {
184
- const group = deliveryGroup(delivery);
185
- const payload = groups.get(group) ?? {
186
- subscription: delivery.subscription.name,
187
- events: []
188
- };
189
- payload.events.push(delivery.event);
190
- groups.set(group, payload);
191
- }
192
- for (const [group, payload] of groups) await deliverOrdered.enqueue(trx, payload, { group });
231
+ await queueOrdered(trx, deliveries);
232
+ for (const { change } of failures) await dispatchChange.enqueue(trx, { change });
193
233
  await deleteOutbox(trx, schema, rows.map((row) => row.id));
194
234
  });
195
235
  for (const [index, { subscription, event }] of now.entries()) report(subscription.name, event, 1, results[index]);
236
+ for (const { error } of failures) log(`events kept as failed (webhooks.dispatch-change): ${error.message}`);
196
237
  return rows.length;
197
238
  };
198
239
  const dispatch = defineJob({
@@ -223,6 +264,7 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
223
264
  });
224
265
  return { jobs: [
225
266
  dispatch,
267
+ dispatchChange,
226
268
  deliver,
227
269
  deliverOrdered,
228
270
  ...table ? [sendTest] : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
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.6",
34
+ "@protobase/query": "0.14.6",
35
+ "@protobase/schema": "0.14.6",
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
  },