@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.
- package/dist/changes/event-code-error.js +14 -0
- package/dist/changes/match-events.js +8 -1
- package/dist/check-views.js +5 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/jobs/job-types.d.ts +1 -1
- package/dist/mail/define-email.d.ts +39 -0
- package/dist/mail/define-email.js +42 -0
- package/dist/mail/smtp-mailer.d.ts +3 -0
- package/dist/mail/smtp-mailer.js +1 -1
- package/dist/view-access.js +7 -3
- package/dist/view-saved-search.js +49 -0
- package/dist/webhooks/route-events.js +7 -1
- package/dist/webhooks/subscriber-view.js +9 -2
- package/dist/webhooks/webhooks.js +64 -22
- package/package.json +7 -4
|
@@ -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) =>
|
|
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`
|
package/dist/check-views.js
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/jobs/job-types.d.ts
CHANGED
|
@@ -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
|
|
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 = {
|
package/dist/mail/smtp-mailer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//#region src/mail/smtp-mailer.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
package/dist/view-access.js
CHANGED
|
@@ -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
|
|
179
|
-
*
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
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
|
|
145
|
+
const changeDeliveries = async (db) => {
|
|
142
146
|
const route = subscriptionRouter(await current(db), models);
|
|
143
147
|
const eventOf = subscriberEvents(deps);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
183
|
-
for (const
|
|
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.
|
|
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.
|
|
34
|
-
"@protobase/query": "0.14.
|
|
35
|
-
"@protobase/schema": "0.14.
|
|
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
|
},
|