@protobase/server 0.45.18 → 0.45.20

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 (43) hide show
  1. package/dist/better-auth/create-auth.d.ts +9 -20
  2. package/dist/better-auth/create-auth.js +9 -1
  3. package/dist/better-auth/end-replaced-session.d.ts +17 -0
  4. package/dist/better-auth/end-replaced-session.js +15 -0
  5. package/dist/better-auth/live-sessions.js +24 -0
  6. package/dist/create-admin.js +6 -3
  7. package/dist/index.d.ts +6 -4
  8. package/dist/index.js +3 -1
  9. package/dist/jobs/current.d.ts +2 -0
  10. package/dist/jobs/job-progress.js +30 -0
  11. package/dist/jobs/job-types.d.ts +5 -0
  12. package/dist/jobs/run-job.d.ts +1 -0
  13. package/dist/jobs/run-job.js +5 -3
  14. package/dist/jobs/runtime.js +7 -1
  15. package/dist/notifications/caller.js +15 -1
  16. package/dist/notifications/deliver.js +133 -0
  17. package/dist/notifications/device-input.js +17 -6
  18. package/dist/notifications/device-store.d.ts +5 -3
  19. package/dist/notifications/device.d.ts +3 -1
  20. package/dist/notifications/guarded-sender.js +13 -4
  21. package/dist/notifications/history.js +2 -1
  22. package/dist/notifications/options.d.ts +1 -1
  23. package/dist/notifications/options.js +1 -0
  24. package/dist/notifications/paths.d.ts +3 -5
  25. package/dist/notifications/paths.js +1 -4
  26. package/dist/notifications/postgres-store.js +4 -0
  27. package/dist/notifications/push-job-name.js +5 -0
  28. package/dist/notifications/push-job.js +52 -0
  29. package/dist/notifications/push-payload.js +60 -0
  30. package/dist/notifications/routes.js +22 -10
  31. package/dist/notifications/schema-sql.js +7 -1
  32. package/dist/notifications/schema.d.ts +2 -1
  33. package/dist/notifications/schema.js +19 -9
  34. package/dist/notifications/send-input.d.ts +28 -0
  35. package/dist/notifications/send-input.js +80 -0
  36. package/dist/notifications/send-notification.d.ts +11 -0
  37. package/dist/notifications/send-notification.js +19 -0
  38. package/dist/notifications/sender.d.ts +4 -3
  39. package/dist/notifications/store-call.js +32 -0
  40. package/dist/notifications/web-push/web-push-sender.d.ts +1 -1
  41. package/dist/notifications/web-push/web-push-sender.js +9 -5
  42. package/dist/package.js +1 -1
  43. package/package.json +4 -4
@@ -1,6 +1,7 @@
1
1
  import { StaffSignIn } from "./staff-log.js";
2
2
  import { RoleDefinitions, RolesInput } from "./organizations/role-definitions.js";
3
3
  import { ConnectedApp } from "./mcp/connected-apps.js";
4
+ import { ReplacingContext } from "./end-replaced-session.js";
4
5
  import { UserDeletionListener } from "./user-deletion.js";
5
6
  import "./auth-base-path.js";
6
7
  import { Mailer } from "../mail/smtp-mailer.js";
@@ -3752,7 +3753,7 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3752
3753
  userId: string;
3753
3754
  expiresAt: Date;
3754
3755
  token: string;
3755
- ipAddress? /** The app keeps each user's metadata, in the column `users.metadata` names. */ : string | null | undefined;
3756
+ ipAddress?: string | null | undefined;
3756
3757
  userAgent?: string | null | undefined;
3757
3758
  };
3758
3759
  user: Record<string, any> & {
@@ -8590,25 +8591,6 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
8590
8591
  })[];
8591
8592
  disabledPaths: string[];
8592
8593
  databaseHooks: {
8593
- session?: {
8594
- create: {
8595
- before: (session: {
8596
- userId: string;
8597
- activeOrganizationId?: string | null;
8598
- } & Record<string, unknown>) => Promise<{
8599
- data: {
8600
- activeOrganizationId: string;
8601
- userId: string;
8602
- };
8603
- } | undefined>;
8604
- };
8605
- update: {
8606
- after: (session: {
8607
- userId: string;
8608
- activeOrganizationId?: string | null;
8609
- }) => Promise<void>;
8610
- };
8611
- } | undefined;
8612
8594
  user: {
8613
8595
  create: {
8614
8596
  after?: ((user: import("better-auth").User & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>) | undefined;
@@ -8654,6 +8636,13 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
8654
8636
  }>;
8655
8637
  };
8656
8638
  };
8639
+ session: {
8640
+ create: {
8641
+ after: (session: {
8642
+ token: string;
8643
+ }, ctx: ReplacingContext | null) => Promise<void>;
8644
+ };
8645
+ };
8657
8646
  };
8658
8647
  rateLimit: {
8659
8648
  enabled: true;
@@ -5,6 +5,7 @@ import { defaultSignInPolicy } from "./sign-in-policy.js";
5
5
  import { roleDefinitions } from "./organizations/role-definitions.js";
6
6
  import { signInPolicyPlugin } from "./policy-plugin.js";
7
7
  import { goTrueSessionsPlugin } from "../gotrue/sessions-plugin.js";
8
+ import { endReplacedSession } from "./end-replaced-session.js";
8
9
  import { authBasePath } from "./auth-base-path.js";
9
10
  import { mobileRequests } from "./mobile-requests.js";
10
11
  import { passwordLength } from "./new-account-password.js";
@@ -43,6 +44,13 @@ import { betterAuth } from "better-auth";
43
44
  import { admin, emailOTP, genericOAuth, jwt } from "better-auth/plugins";
44
45
  import { adminAc, userAc } from "better-auth/plugins/admin/access";
45
46
  //#region src/better-auth/create-auth.ts
47
+ const withEndedReplaced = (hooks) => ({
48
+ ...hooks,
49
+ create: {
50
+ ...hooks.create,
51
+ after: endReplacedSession
52
+ }
53
+ });
46
54
  const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
47
55
  const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
48
56
  const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
@@ -244,7 +252,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
244
252
  },
245
253
  update: { before: async (user) => ({ data: confirmedOnUpdate(user, /* @__PURE__ */ new Date()) }) }
246
254
  },
247
- ...organizations && { session: sessionOrganizationHooks(context) }
255
+ session: withEndedReplaced(organizations ? sessionOrganizationHooks(context) : {})
248
256
  },
249
257
  rateLimit: {
250
258
  enabled: true,
@@ -0,0 +1,17 @@
1
+ //#region src/better-auth/end-replaced-session.d.ts
2
+ /** The part of Better Auth's endpoint context that ending a replaced session reads. */
3
+ export type ReplacingContext = {
4
+ getSignedCookie: (name: string, secret: string) => Promise<string | null | false>;
5
+ context: {
6
+ authCookies: {
7
+ sessionToken: {
8
+ name: string;
9
+ };
10
+ };
11
+ secret: string;
12
+ internalAdapter: {
13
+ deleteSession: (token: string) => Promise<void>;
14
+ };
15
+ };
16
+ };
17
+ //#endregion
@@ -0,0 +1,15 @@
1
+ //#region src/better-auth/end-replaced-session.ts
2
+ /**
3
+ * Better Auth's `databaseHooks.session.create.after`: a sign-in in a browser ends the session that browser's cookie
4
+ * held, whoever it was for, as the new session's cookie replaces it. Without this the old session would stand until it
5
+ * expired, and with it the devices registered in it, which then kept getting their user's push in someone else's
6
+ * browser. A staff sign-in as someone ends it too: nothing restores the earlier session when it stops.
7
+ */
8
+ const endReplacedSession = async (session, ctx) => {
9
+ if (!ctx) return;
10
+ const held = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
11
+ if (!held || held === session.token) return;
12
+ await ctx.context.internalAdapter.deleteSession(held);
13
+ };
14
+ //#endregion
15
+ export { endReplacedSession };
@@ -0,0 +1,24 @@
1
+ //#region src/better-auth/live-sessions.ts
2
+ /**
3
+ * Which of the sessions with these ids still stand, read from the store: each live one's id with its user's id. A
4
+ * session signed out, revoked, ended by a ban or a newer sign-in in the same browser, or expired is not among them.
5
+ * Reads by the session's id, never its token.
6
+ */
7
+ const liveSessionsIn = async (context, ids, now = /* @__PURE__ */ new Date()) => {
8
+ const live = /* @__PURE__ */ new Map();
9
+ if (ids.length === 0) return live;
10
+ const rows = await context.adapter.findMany({
11
+ model: "session",
12
+ where: [{
13
+ field: "id",
14
+ operator: "in",
15
+ value: [...ids]
16
+ }]
17
+ });
18
+ for (const row of rows) if (ids.includes(row.id) && new Date(row.expiresAt) > now) live.set(row.id, String(row.userId));
19
+ return live;
20
+ };
21
+ /** `liveSessionsIn` on the store of this Better Auth instance. */
22
+ const liveSessions = (auth) => async (ids) => liveSessionsIn(await auth.$context, ids);
23
+ //#endregion
24
+ export { liveSessions, liveSessionsIn };
@@ -57,6 +57,8 @@ import { devicesPath, historyPath } from "./notifications/paths.js";
57
57
  import { historyRoutes } from "./notifications/history-routes.js";
58
58
  import { deviceRoutes } from "./notifications/routes.js";
59
59
  import { notificationSenders, webPushKey } from "./notifications/senders-of.js";
60
+ import { pushJob } from "./notifications/push-job.js";
61
+ import { liveSessions } from "./better-auth/live-sessions.js";
60
62
  import { visitorsBrowse, withAnonymous } from "./anonymous.js";
61
63
  import { checkBottomBars, checkShell } from "./check-shell.js";
62
64
  import { checkOwnerFields } from "./check-owners.js";
@@ -105,7 +107,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
105
107
  const appModels = appResources.map((resource) => resource.toModel());
106
108
  const withComments = appModels.some((model) => model.comments);
107
109
  const withAiFields = hasAiFields(appModels);
108
- const withJobs = jobs.length > 0 || appModels.some(isCaptured) || withComments || withAiFields;
110
+ const withJobs = jobs.length > 0 || appModels.some(isCaptured) || withComments || withAiFields || notifications !== void 0;
109
111
  const withHistory = appModels.some((model) => model.history);
110
112
  const changes = changesSchema(events);
111
113
  const days = historyDays(historyOptions);
@@ -373,7 +375,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
373
375
  if (notifications) {
374
376
  const devices = deviceStore(db, notifications);
375
377
  if (auth) removeDevicesWithUsers(auth, devices);
376
- app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices))));
378
+ app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices, { sessionRequired: Boolean(auth) }))));
377
379
  const history = historyStore(db, notifications);
378
380
  if (auth) removeHistoryWithUsers(auth, history);
379
381
  app.route(historyPath(systemPath), guarded((routes) => void routes.route("/", historyRoutes(history, auth?.origins ?? []))));
@@ -435,7 +437,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
435
437
  ...webhooks.jobs,
436
438
  ...withHistory && days !== void 0 ? [pruneHistoryJob(changes, days)] : [],
437
439
  ...mentions ? [mentions] : [],
438
- ...fill ? [fill] : []
440
+ ...fill ? [fill] : [],
441
+ ...notifications ? [pushJob(notifications, senders, auth && liveSessions(auth))] : []
439
442
  ] });
440
443
  };
441
444
  //#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,7 @@ 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 { HistoryPosition, NewNotification, StoredNotification, historyIdPattern, historyLimits } from "./notifications/history.js";
25
27
  import { HistoryStore, maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
26
- import { PushMessage, PushMessageError, Urgency, WebPushFields, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
27
28
  import { PushAnswer, PushSender } from "./notifications/sender.js";
28
29
  import { DeviceColumn, HistoryColumn, NotificationsOptions } from "./notifications/options.js";
29
30
  import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
@@ -104,6 +105,7 @@ import { historyStore, removeHistoryWithUsers } from "./notifications/history-st
104
105
  import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
105
106
  import { defaultHistoryPage } from "./notifications/history-routes.js";
106
107
  import { notificationSenders } from "./notifications/senders-of.js";
108
+ import { sendNotification } from "./notifications/send-notification.js";
107
109
  import { VapidSettings, readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
108
110
  import { WebPushOptions, webPushSender } from "./notifications/web-push/web-push-sender.js";
109
111
  import { generateVapidKeys } from "./notifications/web-push/keys.js";
@@ -158,4 +160,4 @@ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.
158
160
  import { MobileRelease, ReleaseFile, ServedReleaseFile, composeRelease, releaseManifest } from "./mobile-release/release.js";
159
161
  import { MobileReleaseAppInput, OfferedRelease, mobileReleaseApp } from "./mobile-release/release-app.js";
160
162
  import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
161
- 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 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 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, 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, 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
@@ -70,6 +70,7 @@ import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notif
70
70
  import { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
71
71
  import { webPushSender } from "./notifications/web-push/web-push-sender.js";
72
72
  import { notificationSenders } from "./notifications/senders-of.js";
73
+ import { NotificationSendError, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
73
74
  import { errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
74
75
  import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
75
76
  import { limitedFileStore, storeUsage } from "./files/limits.js";
@@ -102,6 +103,7 @@ import { checkWebhooksSchema, webhooksMigration } from "./webhooks/table/schema.
102
103
  import { checkAi } from "./ai/check-ai.js";
103
104
  import { notifications } from "./notifications/plugin.js";
104
105
  import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
106
+ import { sendNotification } from "./notifications/send-notification.js";
105
107
  import { maxWidgetPropsBytes, widget } from "./assistant/widget.js";
106
108
  import { checkQueryRunner, queryRunnerMigration } from "./assistant/query-runner/query-runner.js";
107
109
  import { startErrorReporting } from "./error-reporting/error-reporter.js";
@@ -120,4 +122,4 @@ import { jobsDrainApp } from "./jobs/drain-route.js";
120
122
  import { composeRelease, releaseManifest } from "./mobile-release/release.js";
121
123
  import { mobileReleaseApp } from "./mobile-release/release-app.js";
122
124
  import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
123
- export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, 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, 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, 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, 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,
@@ -12,5 +12,19 @@ const ownSignInUser = (session) => {
12
12
  if (userId === "" || session.user.id === void 0 || session.user.id === null) throw refuse();
13
13
  return userId;
14
14
  };
15
+ /**
16
+ * The caller of a device request: `ownSignInUser`'s user, and the id of the session they signed in with, when their
17
+ * authenticator names one. With `sessionRequired`, as with Protobase's auth, whose devices get push only while their
18
+ * session stands, a caller without one is refused too.
19
+ */
20
+ const ownSignInDevice = (session, sessionRequired) => {
21
+ const userId = ownSignInUser(session);
22
+ const sessionId = session.signIn?.session;
23
+ if (sessionRequired && (typeof sessionId !== "string" || sessionId === "")) throw forbidden("own-sign-in-only", "Only someone signed in as themselves can reach their devices and notifications");
24
+ return {
25
+ userId,
26
+ sessionId: typeof sessionId === "string" && sessionId !== "" ? sessionId : null
27
+ };
28
+ };
15
29
  //#endregion
16
- export { ownSignInUser };
30
+ export { ownSignInDevice, ownSignInUser };
@@ -0,0 +1,133 @@
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. With
19
+ * `liveSessions`, a device whose session no longer stands is removed instead of pushed to. Each kind's
20
+ * devices go to its sender in one batch; a kind without a sender is skipped. An answer of `gone` removes the device,
21
+ * `retry` leaves it to the next try once and gives up on it the second time, and any other is done. A sender that
22
+ * throws leaves its batch to the next try. The try then throws, counting what is left, so the job retries it, or
23
+ * completes the send.
24
+ */
25
+ const deliver = async ({ to, message }, given, steps) => {
26
+ const progress = {
27
+ recorded: [...given.recorded],
28
+ done: [...given.done],
29
+ retried: [...given.retried]
30
+ };
31
+ const recorded = new Set(progress.recorded);
32
+ const done = new Set(progress.done);
33
+ const retried = new Set(progress.retried);
34
+ const left = tally();
35
+ const markDone = (id) => {
36
+ if (done.has(id)) return;
37
+ done.add(id);
38
+ progress.done.push(id);
39
+ };
40
+ if (steps.senders.length === 0 && !("userIds" in to && to.inbox)) return steps.complete();
41
+ const answered = async (sender, batch) => {
42
+ try {
43
+ return await sender.send(message, batch);
44
+ } catch (error) {
45
+ if (error instanceof PushSenderError) return void 0;
46
+ if (!(error instanceof PushMessageError)) throw error;
47
+ left.refusedField = error.field;
48
+ return batch.map((device) => ({
49
+ id: device.id,
50
+ result: "refused"
51
+ }));
52
+ }
53
+ };
54
+ const settle = async (device, answer) => {
55
+ if (answer.result === "gone") await storeCall("removeGone", () => steps.devices.removeGone([device.token]));
56
+ if (answer.result !== "retry" || retried.has(device.id)) return markDone(device.id);
57
+ retried.add(device.id);
58
+ progress.retried.push(device.id);
59
+ left.retried += 1;
60
+ };
61
+ const standing = async (devices) => {
62
+ const { liveSessions } = steps;
63
+ if (!liveSessions) return devices;
64
+ const pending = devices.filter((device) => !done.has(device.id));
65
+ const live = await liveSessions([...new Set(pending.flatMap((device) => device.sessionId === null ? [] : [device.sessionId]))]);
66
+ const stands = (device) => device.sessionId !== null && live.get(device.sessionId) === device.userId;
67
+ for (const device of pending.filter((device) => !stands(device))) {
68
+ await storeCall("remove", () => steps.devices.remove(device.userId, device.id));
69
+ markDone(device.id);
70
+ }
71
+ return pending.filter(stands);
72
+ };
73
+ const push = async (given) => {
74
+ const devices = await standing(given);
75
+ for (const device of devices) if (!steps.senders.some((sender) => sender.kinds.includes(device.kind))) markDone(device.id);
76
+ for (const sender of steps.senders) {
77
+ const batch = devices.filter((device) => sender.kinds.includes(device.kind) && !done.has(device.id));
78
+ if (batch.length === 0) continue;
79
+ await steps.hold();
80
+ const answers = await answered(sender, batch);
81
+ if (answers === void 0) left.retried += batch.length;
82
+ const byId = new Map((answers ?? []).map((answer) => [answer.id, answer]));
83
+ for (const device of answers ? batch : []) await settle(device, byId.get(device.id) ?? {
84
+ id: device.id,
85
+ result: "retry"
86
+ });
87
+ await steps.save(progress);
88
+ }
89
+ };
90
+ const forRecipient = async (run) => {
91
+ try {
92
+ await run();
93
+ } catch (error) {
94
+ if (!(error instanceof NotificationStoreError)) throw error;
95
+ counted(left.storeFailures, error.method);
96
+ }
97
+ };
98
+ if ("userIds" in to) for (const userId of to.userIds) await forRecipient(async () => {
99
+ if (to.inbox && !recorded.has(userId)) {
100
+ const next = {
101
+ ...progress,
102
+ recorded: [...progress.recorded, userId]
103
+ };
104
+ await storeCall("record", () => steps.record(userId, historyEntry(message), next));
105
+ recorded.add(userId);
106
+ progress.recorded.push(userId);
107
+ }
108
+ const devices = await storeCall("listForUser", () => steps.devices.listForUser(userId));
109
+ const own = devices.filter((device) => device.userId === userId);
110
+ if (own.length < devices.length) counted(left.strays, "listForUser", devices.length - own.length);
111
+ await push(own);
112
+ });
113
+ else {
114
+ const wanted = new Set(to.deviceIds);
115
+ const ids = to.deviceIds.filter((id) => !done.has(id));
116
+ if (ids.length > 0) await forRecipient(async () => {
117
+ const devices = await storeCall("listByIds", () => steps.devices.listByIds(ids));
118
+ const asked = devices.filter((device) => wanted.has(device.id));
119
+ if (asked.length < devices.length) counted(left.strays, "listByIds", devices.length - asked.length);
120
+ await push(asked);
121
+ });
122
+ }
123
+ if (left.refusedField !== void 0) throw new FatalJobError(`notifications.send: a sender refused the message: ${left.refusedField}`);
124
+ const strays = left.strays.size > 0 ? `; devices the store gave that were not the recipients', which got nothing: ${listed(left.strays)}` : "";
125
+ if (left.retried > 0 || left.storeFailures.size > 0) {
126
+ const failures = left.storeFailures.size > 0 ? `; store failures: ${listed(left.storeFailures)}` : "";
127
+ throw new Error(`notifications.send: ${left.retried} deliveries are retried${failures}${strays}`);
128
+ }
129
+ await steps.complete();
130
+ if (strays) throw new FatalJobError(`notifications.send: delivered${strays}`);
131
+ };
132
+ //#endregion
133
+ export { deliver };
@@ -7,6 +7,7 @@ const text = (max) => z.string().min(1).max(max).regex(/^[^\u0000-\u001f\u007f]*
7
7
  const base64url = (bytes) => z.string().max(200).regex(/^[A-Za-z0-9_-]+$/).refine((value) => Buffer.from(value, "base64url").length === bytes);
8
8
  const appId = z.string().regex(/^[A-Za-z0-9._-]{1,155}$/);
9
9
  const userAgent = text(512).optional();
10
+ const userId = text(255).optional();
10
11
  const web = z.strictObject({
11
12
  kind: z.literal("web"),
12
13
  endpoint: text(2048).refine(isPushEndpoint),
@@ -14,20 +15,23 @@ const web = z.strictObject({
14
15
  p256dh: base64url(65),
15
16
  auth: base64url(16)
16
17
  }),
17
- userAgent
18
+ userAgent,
19
+ userId
18
20
  });
19
21
  const apns = z.strictObject({
20
22
  kind: z.literal("apns"),
21
23
  token: z.string().regex(/^[0-9a-f]{64,200}$/),
22
24
  environment: z.enum(apnsEnvironments),
23
25
  appId,
24
- userAgent
26
+ userAgent,
27
+ userId
25
28
  });
26
29
  const fcm = z.strictObject({
27
30
  kind: z.literal("fcm"),
28
31
  token: z.string().regex(/^[A-Za-z0-9_:-]{1,4096}$/),
29
32
  appId: appId.optional(),
30
- userAgent
33
+ userAgent,
34
+ userId
31
35
  });
32
36
  const registration = z.discriminatedUnion("kind", [
33
37
  web,
@@ -37,8 +41,9 @@ const registration = z.discriminatedUnion("kind", [
37
41
  /** The largest register body, in bytes. */
38
42
  const maxDeviceBodyBytes = 8192;
39
43
  /**
40
- * The device a register request's JSON body describes, or a 400 that names the first field at fault and never its
41
- * value: one kind's fields only, each within its format and length, and nothing else, such as a user.
44
+ * The device a register request's JSON body describes, and the user it names when it names one, or a 400 that names
45
+ * the first field at fault and never its value: one kind's fields only, each within its format and length, and
46
+ * nothing else.
42
47
  */
43
48
  const deviceInput = (body) => {
44
49
  const parsed = registration.safeParse(body);
@@ -46,7 +51,13 @@ const deviceInput = (body) => {
46
51
  const field = parsed.error.issues[0].path.join(".") || "body";
47
52
  throw badRequest("invalid-device", `The device is invalid: ${field}`);
48
53
  }
49
- const device = parsed.data;
54
+ const { userId } = parsed.data;
55
+ return {
56
+ device: deviceOf(parsed.data),
57
+ ...userId !== void 0 && { userId }
58
+ };
59
+ };
60
+ const deviceOf = (device) => {
50
61
  const base = { userAgent: device.userAgent ?? null };
51
62
  if (device.kind === "web") return {
52
63
  ...base,