@protobase/server 0.45.17 → 0.45.19

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.
Files changed (48) hide show
  1. package/dist/create-admin.js +10 -3
  2. package/dist/index.d.ts +12 -5
  3. package/dist/index.js +9 -2
  4. package/dist/jobs/current.d.ts +2 -0
  5. package/dist/jobs/job-progress.js +30 -0
  6. package/dist/jobs/job-types.d.ts +5 -0
  7. package/dist/jobs/run-job.d.ts +1 -0
  8. package/dist/jobs/run-job.js +5 -3
  9. package/dist/jobs/runtime.js +7 -1
  10. package/dist/notifications/caller.js +6 -6
  11. package/dist/notifications/deliver.js +119 -0
  12. package/dist/notifications/guarded-sender.js +13 -4
  13. package/dist/notifications/history-cursor.js +40 -0
  14. package/dist/notifications/history-request.js +40 -0
  15. package/dist/notifications/history-routes.d.ts +6 -0
  16. package/dist/notifications/history-routes.js +84 -0
  17. package/dist/notifications/history-sql.js +31 -0
  18. package/dist/notifications/history-store-of.d.ts +13 -0
  19. package/dist/notifications/history-store-of.js +13 -0
  20. package/dist/notifications/history-store.d.ts +29 -0
  21. package/dist/notifications/history-store.js +39 -0
  22. package/dist/notifications/history-url.js +18 -0
  23. package/dist/notifications/history.d.ts +34 -0
  24. package/dist/notifications/history.js +42 -0
  25. package/dist/notifications/options.d.ts +11 -0
  26. package/dist/notifications/options.js +32 -13
  27. package/dist/notifications/paths.d.ts +10 -0
  28. package/dist/notifications/paths.js +16 -1
  29. package/dist/notifications/plugin.d.ts +4 -2
  30. package/dist/notifications/plugin.js +4 -2
  31. package/dist/notifications/postgres-history-store.d.ts +15 -0
  32. package/dist/notifications/postgres-history-store.js +91 -0
  33. package/dist/notifications/push-job-name.js +5 -0
  34. package/dist/notifications/push-job.js +50 -0
  35. package/dist/notifications/push-payload.js +60 -0
  36. package/dist/notifications/routes.js +2 -2
  37. package/dist/notifications/schema.d.ts +4 -8
  38. package/dist/notifications/schema.js +34 -11
  39. package/dist/notifications/send-input.d.ts +28 -0
  40. package/dist/notifications/send-input.js +80 -0
  41. package/dist/notifications/send-notification.d.ts +11 -0
  42. package/dist/notifications/send-notification.js +19 -0
  43. package/dist/notifications/sender.d.ts +4 -3
  44. package/dist/notifications/store-call.js +32 -0
  45. package/dist/notifications/web-push/web-push-sender.d.ts +1 -1
  46. package/dist/notifications/web-push/web-push-sender.js +2 -2
  47. package/dist/package.js +1 -1
  48. package/package.json +4 -4
@@ -52,9 +52,12 @@ import { createWebhooks } from "./webhooks/webhooks.js";
52
52
  import { webhookTable } from "./webhooks/table/webhook-table.js";
53
53
  import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
54
54
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
55
- import { devicesPath } from "./notifications/paths.js";
55
+ import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
56
+ import { devicesPath, historyPath } from "./notifications/paths.js";
57
+ import { historyRoutes } from "./notifications/history-routes.js";
56
58
  import { deviceRoutes } from "./notifications/routes.js";
57
59
  import { notificationSenders, webPushKey } from "./notifications/senders-of.js";
60
+ import { pushJob } from "./notifications/push-job.js";
58
61
  import { visitorsBrowse, withAnonymous } from "./anonymous.js";
59
62
  import { checkBottomBars, checkShell } from "./check-shell.js";
60
63
  import { checkOwnerFields } from "./check-owners.js";
@@ -103,7 +106,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
103
106
  const appModels = appResources.map((resource) => resource.toModel());
104
107
  const withComments = appModels.some((model) => model.comments);
105
108
  const withAiFields = hasAiFields(appModels);
106
- const withJobs = jobs.length > 0 || appModels.some(isCaptured) || withComments || withAiFields;
109
+ const withJobs = jobs.length > 0 || appModels.some(isCaptured) || withComments || withAiFields || notifications !== void 0;
107
110
  const withHistory = appModels.some((model) => model.history);
108
111
  const changes = changesSchema(events);
109
112
  const days = historyDays(historyOptions);
@@ -372,6 +375,9 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
372
375
  const devices = deviceStore(db, notifications);
373
376
  if (auth) removeDevicesWithUsers(auth, devices);
374
377
  app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices))));
378
+ const history = historyStore(db, notifications);
379
+ if (auth) removeHistoryWithUsers(auth, history);
380
+ app.route(historyPath(systemPath), guarded((routes) => void routes.route("/", historyRoutes(history, auth?.origins ?? []))));
375
381
  }
376
382
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
377
383
  const feed = tableFeed({
@@ -430,7 +436,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
430
436
  ...webhooks.jobs,
431
437
  ...withHistory && days !== void 0 ? [pruneHistoryJob(changes, days)] : [],
432
438
  ...mentions ? [mentions] : [],
433
- ...fill ? [fill] : []
439
+ ...fill ? [fill] : [],
440
+ ...notifications ? [pushJob(notifications, senders)] : []
434
441
  ] });
435
442
  };
436
443
  //#endregion
package/dist/index.d.ts CHANGED
@@ -13,6 +13,10 @@ import { EmailComponent, EmailOptions, EmailTemplate, RenderedEmail, defineEmail
13
13
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
14
14
  import { EventsOptions } from "./changes/events-options.js";
15
15
  import { StandardSchema, readJson } from "./functions/read-json.js";
16
+ import { JsonObject, JsonValue } from "./notifications/json-value.js";
17
+ import { HistoryPosition, NewNotification, StoredNotification, historyIdPattern, historyLimits } from "./notifications/history.js";
18
+ import { PushMessage, PushMessageError, Urgency, WebPushFields, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
19
+ import { NotificationRecipients, NotificationSendError, SendNotification, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
16
20
  import { EnqueueOptions, JobContext, JobDefinition, JobOptions } from "./jobs/job-types.js";
17
21
  import { Admin, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, SessionOrganization, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
18
22
  import { CommentUsers } from "./comments/comment-users.js";
@@ -20,10 +24,9 @@ import { HistoryOptions } from "./history/options.js";
20
24
  import { WebhooksOptions } from "./webhooks/table/options.js";
21
25
  import { ApnsEnvironment, Device, DeviceKind, NewDevice } from "./notifications/device.js";
22
26
  import { DeviceStore } from "./notifications/device-store.js";
23
- import { JsonObject, JsonValue } from "./notifications/json-value.js";
24
- import { PushMessage, PushMessageError, Urgency, WebPushFields, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
27
+ import { HistoryStore, maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
25
28
  import { PushAnswer, PushSender } from "./notifications/sender.js";
26
- import { DeviceColumn, NotificationsOptions } from "./notifications/options.js";
29
+ import { DeviceColumn, HistoryColumn, NotificationsOptions } from "./notifications/options.js";
27
30
  import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
28
31
  import { Sharp, renderVariant, thumbHash } from "./files/images.js";
29
32
  import { FileProvider, FileStore, StoredObject } from "./files/store.js";
@@ -96,9 +99,13 @@ import { notifications } from "./notifications/plugin.js";
96
99
  import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
97
100
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
98
101
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
99
- import { deviceIdParam, devicesPath } from "./notifications/paths.js";
102
+ import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "./notifications/paths.js";
100
103
  import { maxDevicesPerUser } from "./notifications/routes.js";
104
+ import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
105
+ import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
106
+ import { defaultHistoryPage } from "./notifications/history-routes.js";
101
107
  import { notificationSenders } from "./notifications/senders-of.js";
108
+ import { sendNotification } from "./notifications/send-notification.js";
102
109
  import { VapidSettings, readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
103
110
  import { WebPushOptions, webPushSender } from "./notifications/web-push/web-push-sender.js";
104
111
  import { generateVapidKeys } from "./notifications/web-push/keys.js";
@@ -153,4 +160,4 @@ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.
153
160
  import { MobileRelease, ReleaseFile, ServedReleaseFile, composeRelease, releaseManifest } from "./mobile-release/release.js";
154
161
  import { MobileReleaseAppInput, OfferedRelease, mobileReleaseApp } from "./mobile-release/release-app.js";
155
162
  import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
156
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, 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 FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, MissingCredentials, type MobileConfig, type MobileRelease, type MobileReleaseAppInput, ModelError, type ModelSettings, type NewDevice, type NewOrganization, type NewUser, type NotificationsOptions, type OfferedRelease, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type ReleaseFile, 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 ServedReleaseFile, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, 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 StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxTokenTtlSeconds, maxTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
163
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, 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 FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, MissingCredentials, type MobileConfig, type MobileRelease, type MobileReleaseAppInput, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OfferedRelease, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type ReleaseFile, 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 SendNotification, type ServedReleaseFile, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
package/dist/index.js CHANGED
@@ -56,15 +56,21 @@ import { recordOf, resourceChange } from "./changes/resource-change.js";
56
56
  import { defineFunction } from "./functions/define-function.js";
57
57
  import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
58
58
  import { subscription } from "./webhooks/subscription.js";
59
+ import { maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
59
60
  import { generateVapidKeys } from "./notifications/web-push/keys.js";
60
61
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
61
62
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
62
- import { deviceIdParam, devicesPath } from "./notifications/paths.js";
63
+ import { historyIdPattern, historyLimits } from "./notifications/history.js";
64
+ import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
65
+ import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
66
+ import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "./notifications/paths.js";
67
+ import { defaultHistoryPage } from "./notifications/history-routes.js";
63
68
  import { maxDevicesPerUser } from "./notifications/routes.js";
64
69
  import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
65
70
  import { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
66
71
  import { webPushSender } from "./notifications/web-push/web-push-sender.js";
67
72
  import { notificationSenders } from "./notifications/senders-of.js";
73
+ import { NotificationSendError, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
68
74
  import { errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
69
75
  import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
70
76
  import { limitedFileStore, storeUsage } from "./files/limits.js";
@@ -97,6 +103,7 @@ import { checkWebhooksSchema, webhooksMigration } from "./webhooks/table/schema.
97
103
  import { checkAi } from "./ai/check-ai.js";
98
104
  import { notifications } from "./notifications/plugin.js";
99
105
  import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
106
+ import { sendNotification } from "./notifications/send-notification.js";
100
107
  import { maxWidgetPropsBytes, widget } from "./assistant/widget.js";
101
108
  import { checkQueryRunner, queryRunnerMigration } from "./assistant/query-runner/query-runner.js";
102
109
  import { startErrorReporting } from "./error-reporting/error-reporter.js";
@@ -115,4 +122,4 @@ import { jobsDrainApp } from "./jobs/drain-route.js";
115
122
  import { composeRelease, releaseManifest } from "./mobile-release/release.js";
116
123
  import { mobileReleaseApp } from "./mobile-release/release-app.js";
117
124
  import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
118
- export { ConnectionsBusy, DeviceStoreError, FatalJobError, HttpProblem, MissingCredentials, ModelError, PushMessageError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxTokenTtlSeconds, maxTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
125
+ export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, HttpProblem, MissingCredentials, ModelError, NotificationSendError, PushMessageError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
@@ -4,6 +4,8 @@ import { Db } from "@protobase/query";
4
4
  /** What `defineJob(...).enqueue` reaches: the jobs runtime this process started. */
5
5
  export type JobsEnqueuer = {
6
6
  enqueue(definition: JobDefinition, db: Db, payload: unknown, options?: EnqueueOptions): Promise<string>;
7
+ /** The job of this name among the jobs this runtime took, or `undefined`. */
8
+ definition(name: string): JobDefinition | undefined;
7
9
  /** Has this process's worker of the job look for due jobs now rather than at its next poll; nothing without one. */
8
10
  wake(name: string): void;
9
11
  };
@@ -0,0 +1,30 @@
1
+ import { jobsSchema } from "./schema.js";
2
+ import { sql } from "kysely";
3
+ //#region src/jobs/job-progress.ts
4
+ /** Thrown when a try no longer holds its job: its time ran out and the job was failed, retried or taken by another try. */
5
+ var StaleJobError = class extends Error {
6
+ name = "StaleJobError";
7
+ constructor(name) {
8
+ super(`Job "${name}" is no longer this try's: its time ran out`);
9
+ }
10
+ };
11
+ const held = (job) => sql`name = ${job.name} and id = ${job.id}::uuid and state = 'active' and started_on > ${job.startedOn}::timestamptz - interval '1 millisecond' and started_on < ${job.startedOn}::timestamptz + interval '1 millisecond'`;
12
+ const table = sql.id(jobsSchema, "job");
13
+ /** Throws a `StaleJobError` unless this try still holds the job. */
14
+ const holdJob = async (db, job) => {
15
+ const { rows } = await sql`select 1 from ${table} where ${held(job)}`.execute(db);
16
+ if (rows.length === 0) throw new StaleJobError(job.name);
17
+ };
18
+ const updated = async (db, job, data) => {
19
+ if ((await sql`update ${table} set data = ${data} where ${held(job)}`.execute(db)).numAffectedRows !== 1n) throw new StaleJobError(job.name);
20
+ };
21
+ /**
22
+ * Keeps `progress` in the job's payload under `progress`, where its next try finds it: pg-boss keeps a job's data
23
+ * when it retries it. Only while this try holds the job, or it throws a `StaleJobError`; in `db`'s transaction when it
24
+ * is one, so the work it marks commits with it.
25
+ */
26
+ const saveJobProgress = (db, job, progress) => updated(db, job, sql`jsonb_set(data, '{progress}', ${JSON.stringify(progress)}::jsonb)`);
27
+ /** Replaces the job's payload with `data`, as when what it held is done with; only while this try holds the job. */
28
+ const replaceJobData = (db, job, data) => updated(db, job, sql`${JSON.stringify(data)}::jsonb`);
29
+ //#endregion
30
+ export { StaleJobError, holdJob, replaceJobData, saveJobProgress };
@@ -1,21 +1,26 @@
1
1
  import { Mailer } from "../mail/smtp-mailer.js";
2
2
  import { StandardSchema } from "../functions/read-json.js";
3
+ import { SendNotification } from "../notifications/send-input.js";
3
4
  import { Db } from "@protobase/query";
4
5
  //#region src/jobs/job-types.d.ts
5
6
  /** What `run` gets beside the payload. */
6
7
  export type JobContext = {
7
8
  /** The database pool. The enqueuer's transaction has committed by the time a job runs. */
8
9
  db: Db;
10
+ /** The job, and when this try took it: each try takes it at a new time. */
9
11
  job: {
10
12
  id: string;
11
13
  name: string;
12
14
  attempt: number;
13
15
  maxAttempts: number;
16
+ startedOn: Date;
14
17
  };
15
18
  /** Aborted when the job's timeout passes, or when the worker shuts down and its grace period ends. */
16
19
  signal: AbortSignal;
17
20
  /** Sends mail through the app's SMTP server (`PROTOBASE_SMTP_URL`, `PROTOBASE_MAIL_FROM`); `undefined` when the app has no mail. */
18
21
  mail?: Mailer;
22
+ /** Sends a notification as `sendNotification` does; `undefined` when the app has no `notifications()`. */
23
+ sendNotification?: SendNotification;
19
24
  };
20
25
  export type JobOptions<Payload> = {
21
26
  /** Letters, digits, `_`, `-`, `.` and `/`; the job's queue in the `jobs` schema. */
@@ -1,3 +1,4 @@
1
+ import "../notifications/send-input.js";
1
2
  import "./job-types.js";
2
3
  import "pg-boss";
3
4
  import { Db } from "@protobase/query";
@@ -53,7 +53,7 @@ const namedTimeout = (definition, error) => {
53
53
  * One try of one job: completed when `run` returns, failed (to be retried while attempts are left) when it throws, and
54
54
  * failed for good on a `FatalJobError` or an invalid payload.
55
55
  */
56
- const runJob = async (definition, job, db, mail) => {
56
+ const runJob = async (definition, job, db, { mail, sendNotification } = {}) => {
57
57
  try {
58
58
  const payload = await checkPayload(definition, job.data ?? void 0);
59
59
  const context = {
@@ -62,10 +62,12 @@ const runJob = async (definition, job, db, mail) => {
62
62
  id: job.id,
63
63
  name: job.name,
64
64
  attempt: job.retryCount + 1,
65
- maxAttempts: job.retryLimit + 1
65
+ maxAttempts: job.retryLimit + 1,
66
+ startedOn: job.startedOn
66
67
  },
67
68
  signal: job.signal,
68
- ...mail && { mail }
69
+ ...mail && { mail },
70
+ ...sendNotification && { sendNotification }
69
71
  };
70
72
  await throughWriteFreeze(() => definition.options.run(payload, context), job.signal);
71
73
  return {
@@ -3,7 +3,9 @@ import { jobsSchema } from "./schema.js";
3
3
  import "./schedule.js";
4
4
  import { runJob, validatePayload } from "./run-job.js";
5
5
  import { platformMailer } from "../mail/platform-mailer.js";
6
+ import "../notifications/push-job-name.js";
6
7
  import { runInBackground } from "../background-work.js";
8
+ import { sendNotification } from "../notifications/send-notification.js";
7
9
  import { busyDatabase } from "./busy-database.js";
8
10
  import { jobsDatabase } from "./database.js";
9
11
  import { queueOptions, queueSettingsDiffer, sendOptions, workOptions } from "./options.js";
@@ -56,7 +58,10 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
56
58
  const current = definitions.get(definition.name) ?? definition;
57
59
  return runInBackground(async () => {
58
60
  if (current.options.schedule !== void 0 && job.singletonKey === "protobase.schedule") await schedules.following(current, job.startAfter);
59
- return [await runJob(current, job, db, mail)];
61
+ return [await runJob(current, job, db, {
62
+ mail,
63
+ ...definitions.has("notifications.send") && { sendNotification }
64
+ })];
60
65
  }, { longQueries: current.options.longQueries });
61
66
  });
62
67
  workers.set(definition.name, {
@@ -139,6 +144,7 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
139
144
  const runtime = {
140
145
  works: work,
141
146
  enqueue,
147
+ definition: (name) => definitions.get(name),
142
148
  wake,
143
149
  update,
144
150
  drain,
@@ -1,16 +1,16 @@
1
1
  import { forbidden } from "../problem.js";
2
2
  //#region src/notifications/caller.ts
3
3
  /**
4
- * The id of the user whose devices a request may register and remove: only someone signed in as themselves. Refused:
5
- * visitors, outside apps acting for the user (an MCP client), and every session whose authenticator does not mark it as
6
- * the user's own sign-in (`Session.signIn`), staff signed in as the user included.
4
+ * The id of the user whose devices and notification history a request may reach: only someone signed in as themselves.
5
+ * Refused: visitors, outside apps acting for the user (an MCP client), and every session whose authenticator does not
6
+ * mark it as the user's own sign-in (`Session.signIn`), staff signed in as the user included.
7
7
  */
8
- const ownDeviceUser = (session) => {
9
- const refuse = () => forbidden("own-sign-in-only", "Only someone signed in as themselves can change their devices");
8
+ const ownSignInUser = (session) => {
9
+ const refuse = () => forbidden("own-sign-in-only", "Only someone signed in as themselves can reach their devices and notifications");
10
10
  if (session.anonymous || session.client || session.signIn?.staff !== false) throw refuse();
11
11
  const userId = String(session.user.id);
12
12
  if (userId === "" || session.user.id === void 0 || session.user.id === null) throw refuse();
13
13
  return userId;
14
14
  };
15
15
  //#endregion
16
- export { ownDeviceUser };
16
+ export { ownSignInUser };
@@ -0,0 +1,119 @@
1
+ import { FatalJobError } from "../jobs/run-job.js";
2
+ import { PushMessageError } from "./message.js";
3
+ import { PushSenderError } from "./guarded-sender.js";
4
+ import { historyEntry } from "./send-input.js";
5
+ import { NotificationStoreError, storeCall } from "./store-call.js";
6
+ //#region src/notifications/deliver.ts
7
+ const tally = () => ({
8
+ retried: 0,
9
+ storeFailures: /* @__PURE__ */ new Map(),
10
+ strays: /* @__PURE__ */ new Map(),
11
+ refusedField: void 0
12
+ });
13
+ const counted = (counts, method, count = 1) => counts.set(method, (counts.get(method) ?? 0) + count);
14
+ const listed = (counts) => [...counts].map(([method, count]) => `${count} in ${method}`).join(", ");
15
+ /**
16
+ * One try of a send, resuming from `given`: for a user send, each user's history entry when the send is for the inbox
17
+ * (once, as `record` marks it), then the user's devices as the store lists them now; for a device send, the devices
18
+ * with those ids now. A device the store gives that is not the user's, or not one asked for, gets nothing. Each kind's
19
+ * devices go to its sender in one batch; a kind without a sender is skipped. An answer of `gone` removes the device,
20
+ * `retry` leaves it to the next try once and gives up on it the second time, and any other is done. A sender that
21
+ * throws leaves its batch to the next try. The try then throws, counting what is left, so the job retries it, or
22
+ * completes the send.
23
+ */
24
+ const deliver = async ({ to, message }, given, steps) => {
25
+ const progress = {
26
+ recorded: [...given.recorded],
27
+ done: [...given.done],
28
+ retried: [...given.retried]
29
+ };
30
+ const recorded = new Set(progress.recorded);
31
+ const done = new Set(progress.done);
32
+ const retried = new Set(progress.retried);
33
+ const left = tally();
34
+ const markDone = (id) => {
35
+ if (done.has(id)) return;
36
+ done.add(id);
37
+ progress.done.push(id);
38
+ };
39
+ if (steps.senders.length === 0 && !("userIds" in to && to.inbox)) return steps.complete();
40
+ const answered = async (sender, batch) => {
41
+ try {
42
+ return await sender.send(message, batch);
43
+ } catch (error) {
44
+ if (error instanceof PushSenderError) return void 0;
45
+ if (!(error instanceof PushMessageError)) throw error;
46
+ left.refusedField = error.field;
47
+ return batch.map((device) => ({
48
+ id: device.id,
49
+ result: "refused"
50
+ }));
51
+ }
52
+ };
53
+ const settle = async (device, answer) => {
54
+ if (answer.result === "gone") await storeCall("removeGone", () => steps.devices.removeGone([device.token]));
55
+ if (answer.result !== "retry" || retried.has(device.id)) return markDone(device.id);
56
+ retried.add(device.id);
57
+ progress.retried.push(device.id);
58
+ left.retried += 1;
59
+ };
60
+ const push = async (devices) => {
61
+ for (const device of devices) if (!steps.senders.some((sender) => sender.kinds.includes(device.kind))) markDone(device.id);
62
+ for (const sender of steps.senders) {
63
+ const batch = devices.filter((device) => sender.kinds.includes(device.kind) && !done.has(device.id));
64
+ if (batch.length === 0) continue;
65
+ await steps.hold();
66
+ const answers = await answered(sender, batch);
67
+ if (answers === void 0) left.retried += batch.length;
68
+ const byId = new Map((answers ?? []).map((answer) => [answer.id, answer]));
69
+ for (const device of answers ? batch : []) await settle(device, byId.get(device.id) ?? {
70
+ id: device.id,
71
+ result: "retry"
72
+ });
73
+ await steps.save(progress);
74
+ }
75
+ };
76
+ const forRecipient = async (run) => {
77
+ try {
78
+ await run();
79
+ } catch (error) {
80
+ if (!(error instanceof NotificationStoreError)) throw error;
81
+ counted(left.storeFailures, error.method);
82
+ }
83
+ };
84
+ if ("userIds" in to) for (const userId of to.userIds) await forRecipient(async () => {
85
+ if (to.inbox && !recorded.has(userId)) {
86
+ const next = {
87
+ ...progress,
88
+ recorded: [...progress.recorded, userId]
89
+ };
90
+ await storeCall("record", () => steps.record(userId, historyEntry(message), next));
91
+ recorded.add(userId);
92
+ progress.recorded.push(userId);
93
+ }
94
+ const devices = await storeCall("listForUser", () => steps.devices.listForUser(userId));
95
+ const own = devices.filter((device) => device.userId === userId);
96
+ if (own.length < devices.length) counted(left.strays, "listForUser", devices.length - own.length);
97
+ await push(own);
98
+ });
99
+ else {
100
+ const wanted = new Set(to.deviceIds);
101
+ const ids = to.deviceIds.filter((id) => !done.has(id));
102
+ if (ids.length > 0) await forRecipient(async () => {
103
+ const devices = await storeCall("listByIds", () => steps.devices.listByIds(ids));
104
+ const asked = devices.filter((device) => wanted.has(device.id));
105
+ if (asked.length < devices.length) counted(left.strays, "listByIds", devices.length - asked.length);
106
+ await push(asked);
107
+ });
108
+ }
109
+ if (left.refusedField !== void 0) throw new FatalJobError(`notifications.send: a sender refused the message: ${left.refusedField}`);
110
+ const strays = left.strays.size > 0 ? `; devices the store gave that were not the recipients', which got nothing: ${listed(left.strays)}` : "";
111
+ if (left.retried > 0 || left.storeFailures.size > 0) {
112
+ const failures = left.storeFailures.size > 0 ? `; store failures: ${listed(left.storeFailures)}` : "";
113
+ throw new Error(`notifications.send: ${left.retried} deliveries are retried${failures}${strays}`);
114
+ }
115
+ await steps.complete();
116
+ if (strays) throw new FatalJobError(`notifications.send: delivered${strays}`);
117
+ };
118
+ //#endregion
119
+ export { deliver };
@@ -3,8 +3,17 @@ import { PushMessageError, pushMessage } from "./message.js";
3
3
  const results = /* @__PURE__ */ new Set([
4
4
  "ok",
5
5
  "gone",
6
- "retry"
6
+ "retry",
7
+ "refused"
7
8
  ]);
9
+ /** What a guarded sender throws for what its sender threw: it names the sender's index alone. */
10
+ var PushSenderError = class extends Error {
11
+ index;
12
+ constructor(index) {
13
+ super(`notifications sender ${index} failed`);
14
+ this.index = index;
15
+ }
16
+ };
8
17
  const deepFrozen = (value) => {
9
18
  if (typeof value !== "object" || value === null) return value;
10
19
  for (const item of Object.values(value)) deepFrozen(item);
@@ -34,7 +43,7 @@ const answersFor = (batch, given) => {
34
43
  /**
35
44
  * `sender` as the code that sends calls it, whether built in or the app's own: the message is checked as `pushMessage`
36
45
  * checks it, and refused before the sender sees it; the sender gets a frozen copy of it and of the devices of its kinds
37
- * only, and answers each of those once, as above. A sender that throws throws an error of its own naming its index,
46
+ * only, and answers each of those once, as above. A sender that throws throws a `PushSenderError` naming its index,
38
47
  * never the sender's, which may carry an endpoint or a key, but for a `PushMessageError`, which names a field only.
39
48
  */
40
49
  const guardedSender = (sender, index) => {
@@ -47,7 +56,7 @@ const guardedSender = (sender, index) => {
47
56
  answers = await sender.send(message, batch);
48
57
  } catch (error) {
49
58
  if (error instanceof PushMessageError) throw error;
50
- throw new Error(`notifications sender ${index} failed`);
59
+ throw new PushSenderError(index);
51
60
  }
52
61
  return answersFor(batch, answers);
53
62
  };
@@ -58,4 +67,4 @@ const guardedSender = (sender, index) => {
58
67
  };
59
68
  };
60
69
  //#endregion
61
- export { guardedSender };
70
+ export { PushSenderError, guardedSender };
@@ -0,0 +1,40 @@
1
+ import { badRequest } from "../problem.js";
2
+ import { historyIdPattern } from "./history.js";
3
+ //#region src/notifications/history-cursor.ts
4
+ const base64url = /^[A-Za-z0-9_-]{1,200}$/;
5
+ /** The cursor of the page after `position`: its time and id as base64url JSON, nothing of the user or the page size. */
6
+ const historyCursor = (position) => Buffer.from(JSON.stringify({
7
+ t: position.createdAt.getTime(),
8
+ i: position.id
9
+ })).toString("base64url");
10
+ const refused = () => badRequest("invalid-cursor", "The cursor is not one a history page gave");
11
+ const decoded = (cursor) => {
12
+ if (!base64url.test(cursor)) return void 0;
13
+ const bytes = Buffer.from(cursor, "base64url");
14
+ if (bytes.toString("base64url") !== cursor) return void 0;
15
+ const text = bytes.toString("utf8");
16
+ if (!text.startsWith("{")) return void 0;
17
+ return JSON.parse(text);
18
+ };
19
+ /** The position a cursor holds, or a 400 that says nothing of what was wrong with it: exactly a time and an entry id. */
20
+ const historyPosition = (cursor) => {
21
+ let value;
22
+ try {
23
+ value = decoded(cursor);
24
+ } catch (error) {
25
+ if (error instanceof SyntaxError) throw refused();
26
+ throw error;
27
+ }
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw refused();
29
+ const keys = Object.keys(value).sort();
30
+ const { t, i } = value;
31
+ if (keys.join() !== "i,t" || !Number.isSafeInteger(t) || t < 0 || typeof i !== "string" || !historyIdPattern.test(i)) throw refused();
32
+ const createdAt = new Date(t);
33
+ if (Number.isNaN(createdAt.getTime())) throw refused();
34
+ return {
35
+ createdAt,
36
+ id: i
37
+ };
38
+ };
39
+ //#endregion
40
+ export { historyCursor, historyPosition };
@@ -0,0 +1,40 @@
1
+ import { HttpProblem, badRequest } from "../problem.js";
2
+ import { bodyLimit } from "hono/body-limit";
3
+ //#region src/notifications/history-request.ts
4
+ /** The largest body a history request takes, in bytes. */
5
+ const maxHistoryBodyBytes = 1024;
6
+ /** Refuses a body that is not `application/json`, so no plain form posted from another site reaches a mark. */
7
+ const jsonOnly = async (c, next) => {
8
+ if (c.req.header("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") throw new HttpProblem(415, "json-only", "Unsupported Media Type", "Send application/json");
9
+ await next();
10
+ };
11
+ const limitedBody = bodyLimit({
12
+ maxSize: maxHistoryBodyBytes,
13
+ onError: () => {
14
+ throw new HttpProblem(413, "too-large", "Payload Too Large", `The body is larger than ${maxHistoryBodyBytes} bytes`);
15
+ }
16
+ });
17
+ /** The request's JSON body, `undefined` when it is empty, or a 400 when it is no JSON. */
18
+ const jsonBody = async (c) => {
19
+ const text = await c.req.text();
20
+ if (text.trim() === "") return void 0;
21
+ try {
22
+ return JSON.parse(text);
23
+ } catch (error) {
24
+ if (error instanceof SyntaxError) throw badRequest("invalid-json", "The body is not JSON");
25
+ throw error;
26
+ }
27
+ };
28
+ /** The query's parameters, each at most once, or a 400 naming the first that is none of `allowed`. */
29
+ const queryOf = (c, allowed) => {
30
+ const given = c.req.queries();
31
+ const query = {};
32
+ for (const [key, values] of Object.entries(given)) {
33
+ if (!allowed.includes(key)) throw badRequest("unknown-parameter", `This endpoint takes no parameter "${key.slice(0, 64)}"`);
34
+ if (values.length !== 1) throw badRequest("repeated-parameter", `The parameter "${key}" is given more than once`);
35
+ query[key] = values[0];
36
+ }
37
+ return query;
38
+ };
39
+ //#endregion
40
+ export { jsonBody, jsonOnly, limitedBody, maxHistoryBodyBytes, queryOf };
@@ -0,0 +1,6 @@
1
+ import "../types.js";
2
+ import { Hono } from "hono";
3
+ //#region src/notifications/history-routes.d.ts
4
+ /** How many entries a page of the history holds when the request names no `limit`. */
5
+ export declare const defaultHistoryPage = 20;
6
+ //#endregion