@protobase/server 0.45.17 → 0.45.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-admin.js +6 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.js +7 -2
- package/dist/notifications/caller.js +6 -6
- package/dist/notifications/history-cursor.js +40 -0
- package/dist/notifications/history-request.js +40 -0
- package/dist/notifications/history-routes.d.ts +6 -0
- package/dist/notifications/history-routes.js +84 -0
- package/dist/notifications/history-sql.js +31 -0
- package/dist/notifications/history-store-of.d.ts +13 -0
- package/dist/notifications/history-store-of.js +13 -0
- package/dist/notifications/history-store.d.ts +29 -0
- package/dist/notifications/history-store.js +39 -0
- package/dist/notifications/history-url.js +18 -0
- package/dist/notifications/history.d.ts +34 -0
- package/dist/notifications/history.js +41 -0
- package/dist/notifications/options.d.ts +11 -0
- package/dist/notifications/options.js +32 -13
- package/dist/notifications/paths.d.ts +10 -0
- package/dist/notifications/paths.js +16 -1
- package/dist/notifications/plugin.d.ts +4 -2
- package/dist/notifications/plugin.js +4 -2
- package/dist/notifications/postgres-history-store.d.ts +15 -0
- package/dist/notifications/postgres-history-store.js +91 -0
- package/dist/notifications/routes.js +2 -2
- package/dist/notifications/schema.d.ts +4 -8
- package/dist/notifications/schema.js +34 -11
- package/dist/package.js +1 -1
- package/package.json +4 -4
package/dist/create-admin.js
CHANGED
|
@@ -52,7 +52,9 @@ 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 {
|
|
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";
|
|
58
60
|
import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
@@ -372,6 +374,9 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
372
374
|
const devices = deviceStore(db, notifications);
|
|
373
375
|
if (auth) removeDevicesWithUsers(auth, devices);
|
|
374
376
|
app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices))));
|
|
377
|
+
const history = historyStore(db, notifications);
|
|
378
|
+
if (auth) removeHistoryWithUsers(auth, history);
|
|
379
|
+
app.route(historyPath(systemPath), guarded((routes) => void routes.route("/", historyRoutes(history, auth?.origins ?? []))));
|
|
375
380
|
}
|
|
376
381
|
if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
|
|
377
382
|
const feed = tableFeed({
|
package/dist/index.d.ts
CHANGED
|
@@ -21,9 +21,11 @@ import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
|
21
21
|
import { ApnsEnvironment, Device, DeviceKind, NewDevice } from "./notifications/device.js";
|
|
22
22
|
import { DeviceStore } from "./notifications/device-store.js";
|
|
23
23
|
import { JsonObject, JsonValue } from "./notifications/json-value.js";
|
|
24
|
+
import { HistoryPosition, NewNotification, StoredNotification, historyIdPattern, historyLimits } from "./notifications/history.js";
|
|
25
|
+
import { HistoryStore, maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
|
|
24
26
|
import { PushMessage, PushMessageError, Urgency, WebPushFields, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
|
|
25
27
|
import { PushAnswer, PushSender } from "./notifications/sender.js";
|
|
26
|
-
import { DeviceColumn, NotificationsOptions } from "./notifications/options.js";
|
|
28
|
+
import { DeviceColumn, HistoryColumn, NotificationsOptions } from "./notifications/options.js";
|
|
27
29
|
import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
|
|
28
30
|
import { Sharp, renderVariant, thumbHash } from "./files/images.js";
|
|
29
31
|
import { FileProvider, FileStore, StoredObject } from "./files/store.js";
|
|
@@ -96,8 +98,11 @@ import { notifications } from "./notifications/plugin.js";
|
|
|
96
98
|
import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
|
|
97
99
|
import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
|
|
98
100
|
import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
|
|
99
|
-
import { deviceIdParam, devicesPath } from "./notifications/paths.js";
|
|
101
|
+
import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "./notifications/paths.js";
|
|
100
102
|
import { maxDevicesPerUser } from "./notifications/routes.js";
|
|
103
|
+
import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
|
|
104
|
+
import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
|
|
105
|
+
import { defaultHistoryPage } from "./notifications/history-routes.js";
|
|
101
106
|
import { notificationSenders } from "./notifications/senders-of.js";
|
|
102
107
|
import { VapidSettings, readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
|
|
103
108
|
import { WebPushOptions, webPushSender } from "./notifications/web-push/web-push-sender.js";
|
|
@@ -153,4 +158,4 @@ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.
|
|
|
153
158
|
import { MobileRelease, ReleaseFile, ServedReleaseFile, composeRelease, releaseManifest } from "./mobile-release/release.js";
|
|
154
159
|
import { MobileReleaseAppInput, OfferedRelease, mobileReleaseApp } from "./mobile-release/release-app.js";
|
|
155
160
|
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 };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -56,10 +56,15 @@ 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 {
|
|
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";
|
|
@@ -115,4 +120,4 @@ import { jobsDrainApp } from "./jobs/drain-route.js";
|
|
|
115
120
|
import { composeRelease, releaseManifest } from "./mobile-release/release.js";
|
|
116
121
|
import { mobileReleaseApp } from "./mobile-release/release-app.js";
|
|
117
122
|
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 };
|
|
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 };
|
|
@@ -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
|
|
5
|
-
* visitors, outside apps acting for the user (an MCP client), and every session whose authenticator does not
|
|
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
|
|
9
|
-
const refuse = () => forbidden("own-sign-in-only", "Only someone signed in as themselves can
|
|
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 {
|
|
16
|
+
export { ownSignInUser };
|
|
@@ -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,84 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
import "./history-store.js";
|
|
3
|
+
import { historyIdPattern } from "./history.js";
|
|
4
|
+
import { ownSignInUser } from "./caller.js";
|
|
5
|
+
import { historyCursor, historyPosition } from "./history-cursor.js";
|
|
6
|
+
import { jsonBody, jsonOnly, limitedBody, queryOf } from "./history-request.js";
|
|
7
|
+
import { historyUrl } from "./history-url.js";
|
|
8
|
+
import "./paths.js";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
//#region src/notifications/history-routes.ts
|
|
12
|
+
/** How many entries a page of the history holds when the request names no `limit`. */
|
|
13
|
+
const defaultHistoryPage = 20;
|
|
14
|
+
const ownSignIn = async (c, next) => {
|
|
15
|
+
c.set("historyUser", ownSignInUser(c.get("session")));
|
|
16
|
+
await next();
|
|
17
|
+
};
|
|
18
|
+
const noStore = { "Cache-Control": "no-store" };
|
|
19
|
+
const pageLimit = (limit) => {
|
|
20
|
+
if (limit === void 0) return 20;
|
|
21
|
+
const value = /^[1-9][0-9]{0,2}$/.test(limit) ? Number(limit) : 0;
|
|
22
|
+
if (value < 1 || value > 50) throw badRequest("invalid-limit", `The limit is a whole number from 1 to 50`);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const markAll = z.strictObject({ upTo: z.iso.datetime({ offset: true }) });
|
|
26
|
+
/**
|
|
27
|
+
* The caller's own notification history, mounted at `historyPath` behind the API's authenticator: `GET /` a page of
|
|
28
|
+
* it, newest first, `GET /unread` how many are unread, `POST /read` marks all read up to the newest the caller was
|
|
29
|
+
* shown, `POST /<id>/read` one. No request names a user: each reaches the signed-in user's own entries alone. `origins`
|
|
30
|
+
* are the app's own, which an absolute url may lead to.
|
|
31
|
+
*/
|
|
32
|
+
const historyRoutes = (store, origins) => {
|
|
33
|
+
const app = new Hono();
|
|
34
|
+
const answer = (entry) => ({
|
|
35
|
+
id: entry.id,
|
|
36
|
+
title: entry.title,
|
|
37
|
+
body: entry.body,
|
|
38
|
+
url: historyUrl(entry.url, origins),
|
|
39
|
+
data: entry.data,
|
|
40
|
+
createdAt: entry.createdAt.toISOString(),
|
|
41
|
+
readAt: entry.readAt === null ? null : entry.readAt.toISOString()
|
|
42
|
+
});
|
|
43
|
+
app.get("/", ownSignIn, async (c) => {
|
|
44
|
+
const userId = c.get("historyUser");
|
|
45
|
+
const query = queryOf(c, ["cursor", "limit"]);
|
|
46
|
+
const limit = pageLimit(query.limit);
|
|
47
|
+
const before = query.cursor === void 0 ? void 0 : historyPosition(query.cursor);
|
|
48
|
+
const entries = await store.list(userId, {
|
|
49
|
+
...before && { before },
|
|
50
|
+
limit
|
|
51
|
+
});
|
|
52
|
+
if (entries.length > limit || entries.some((entry) => entry.userId !== userId || !historyIdPattern.test(entry.id))) throw new Error("The history store answered list with entries that are not the caller's");
|
|
53
|
+
const last = entries.at(-1);
|
|
54
|
+
const next = last && entries.length === limit ? historyCursor(last) : null;
|
|
55
|
+
return c.json({
|
|
56
|
+
items: entries.map(answer),
|
|
57
|
+
next
|
|
58
|
+
}, 200, noStore);
|
|
59
|
+
});
|
|
60
|
+
app.get("/unread", ownSignIn, async (c) => {
|
|
61
|
+
queryOf(c, []);
|
|
62
|
+
const count = await store.countUnread(c.get("historyUser"));
|
|
63
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > 100) throw new Error("The history store answered countUnread with no count");
|
|
64
|
+
return c.json({ count }, 200, noStore);
|
|
65
|
+
});
|
|
66
|
+
app.post("/read", ownSignIn, jsonOnly, limitedBody, async (c) => {
|
|
67
|
+
queryOf(c, []);
|
|
68
|
+
const parsed = markAll.safeParse(await jsonBody(c));
|
|
69
|
+
if (!parsed.success) throw badRequest("invalid-mark", "Send { \"upTo\": <createdAt of the newest notification shown> }");
|
|
70
|
+
await store.markAllRead(c.get("historyUser"), new Date(parsed.data.upTo));
|
|
71
|
+
return c.body(null, 204);
|
|
72
|
+
});
|
|
73
|
+
app.post(`/:id/read`, ownSignIn, jsonOnly, limitedBody, async (c) => {
|
|
74
|
+
queryOf(c, []);
|
|
75
|
+
const body = await jsonBody(c);
|
|
76
|
+
if (body !== void 0 && (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length > 0)) throw badRequest("invalid-mark", "Marking one read takes no body");
|
|
77
|
+
const id = c.req.param("id");
|
|
78
|
+
if (historyIdPattern.test(id)) await store.markRead(c.get("historyUser"), id);
|
|
79
|
+
return c.body(null, 204);
|
|
80
|
+
});
|
|
81
|
+
return app;
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { defaultHistoryPage, historyRoutes };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { qualified } from "./schema-sql.js";
|
|
3
|
+
//#region src/notifications/history-sql.ts
|
|
4
|
+
/**
|
|
5
|
+
* The history table: with auth, its user column points at auth's users table and goes with the user. `read_at` is NULL
|
|
6
|
+
* while unread and nothing has a default. One index serves a user's newest-first pages, another their unread ones.
|
|
7
|
+
*/
|
|
8
|
+
const historyTableSql = ({ schema, table, columns }, user) => {
|
|
9
|
+
const c = Object.fromEntries(Object.entries(columns).map(([name, value]) => [name, quoteIdent(value)]));
|
|
10
|
+
const history = qualified({
|
|
11
|
+
schema,
|
|
12
|
+
table
|
|
13
|
+
});
|
|
14
|
+
return [
|
|
15
|
+
`create schema if not exists ${quoteIdent(schema)}`,
|
|
16
|
+
`create table ${history} (
|
|
17
|
+
${c.id} uuid primary key,
|
|
18
|
+
${c.user_id} ${user.type} not null${user.references ? ` references ${user.references} on delete cascade` : ""},
|
|
19
|
+
${c.title} text not null,
|
|
20
|
+
${c.body} text,
|
|
21
|
+
${c.url} text,
|
|
22
|
+
${c.data} jsonb,
|
|
23
|
+
${c.created_at} timestamptz not null,
|
|
24
|
+
${c.read_at} timestamptz
|
|
25
|
+
)`,
|
|
26
|
+
`create index on ${history} (${c.user_id}, ${c.created_at} desc, ${c.id} desc)`,
|
|
27
|
+
`create index on ${history} (${c.user_id}, ${c.created_at}) where ${c.read_at} is null`
|
|
28
|
+
];
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { historyTableSql };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AdminAuth } from "../better-auth/create-auth.js";
|
|
2
|
+
import { HistoryStore } from "./history-store.js";
|
|
3
|
+
import { NotificationsOptions } from "./options.js";
|
|
4
|
+
import { Db } from "@protobase/query";
|
|
5
|
+
//#region src/notifications/history-store-of.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* The history's store `notifications()` configures: the app's own `historyStore`, or the history table in `db`. An app
|
|
8
|
+
* that deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
|
|
9
|
+
*/
|
|
10
|
+
export declare const historyStore: (db: Db, options?: NotificationsOptions) => HistoryStore;
|
|
11
|
+
/** Has `auth` remove a user's history from `store` when it deletes the user, before and once more after. */
|
|
12
|
+
export declare const removeHistoryWithUsers: (auth: AdminAuth, store: HistoryStore) => void;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { postgresHistoryStore } from "./postgres-history-store.js";
|
|
2
|
+
//#region src/notifications/history-store-of.ts
|
|
3
|
+
/**
|
|
4
|
+
* The history's store `notifications()` configures: the app's own `historyStore`, or the history table in `db`. An app
|
|
5
|
+
* that deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
|
|
6
|
+
*/
|
|
7
|
+
const historyStore = (db, options = {}) => options.historyStore ?? postgresHistoryStore(db, options);
|
|
8
|
+
/** Has `auth` remove a user's history from `store` when it deletes the user, before and once more after. */
|
|
9
|
+
const removeHistoryWithUsers = (auth, store) => {
|
|
10
|
+
auth.userDeletion.set("notifications-history", (userId) => store.removeAllForUser(userId));
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { historyStore, removeHistoryWithUsers };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HistoryPosition, NewNotification, StoredNotification } from "./history.js";
|
|
2
|
+
//#region src/notifications/history-store.d.ts
|
|
3
|
+
/** The most unread notifications `countUnread` counts: a bell shows "99+" past it. */
|
|
4
|
+
export declare const maxUnreadCount = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Where each user's inbox notifications are kept: the Postgres default (`postgresHistoryStore`), or a store of the
|
|
7
|
+
* app's own passed as `notifications({ historyStore })`. Plain data in and out, so any store can implement it. Every
|
|
8
|
+
* method takes the user and touches that user's entries alone; each throws on an empty user id or id.
|
|
9
|
+
*/
|
|
10
|
+
export type HistoryStore = {
|
|
11
|
+
/** Keeps the notification for `userId`, unread, created now, under a new id matching `historyIdPattern`. */
|
|
12
|
+
record: (userId: string, notification: NewNotification) => Promise<StoredNotification>;
|
|
13
|
+
/** At most `limit` of the user's entries, newest first by creation time then id, starting after `before`. */
|
|
14
|
+
list: (userId: string, page: {
|
|
15
|
+
before?: HistoryPosition;
|
|
16
|
+
limit: number;
|
|
17
|
+
}) => Promise<StoredNotification[]>;
|
|
18
|
+
/** How many of the user's entries are unread, counting up to `maxUnreadCount` at most. */
|
|
19
|
+
countUnread: (userId: string) => Promise<number>;
|
|
20
|
+
/** Marks the user's entry with this id read now, unless it was read already; false when the user has none such. */
|
|
21
|
+
markRead: (userId: string, id: string) => Promise<boolean>;
|
|
22
|
+
/** Marks the user's unread entries created at or before `upTo` read now; entries read already keep their time. */
|
|
23
|
+
markAllRead: (userId: string, upTo: Date) => Promise<void>;
|
|
24
|
+
/** Removes every entry of the user, as when the user is deleted. */
|
|
25
|
+
removeAllForUser: (userId: string) => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
/** The most entries `list` gives at once. */
|
|
28
|
+
export declare const maxHistoryPage = 50;
|
|
29
|
+
//#endregion
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//#region src/notifications/history-store.ts
|
|
2
|
+
/** The most unread notifications `countUnread` counts: a bell shows "99+" past it. */
|
|
3
|
+
const maxUnreadCount = 100;
|
|
4
|
+
const methods = [
|
|
5
|
+
"record",
|
|
6
|
+
"list",
|
|
7
|
+
"countUnread",
|
|
8
|
+
"markRead",
|
|
9
|
+
"markAllRead",
|
|
10
|
+
"removeAllForUser"
|
|
11
|
+
];
|
|
12
|
+
/** The store, checked to have every method; a missing one is an error naming it. */
|
|
13
|
+
const checkHistoryStore = (store) => {
|
|
14
|
+
const missing = methods.find((method) => typeof store[method] !== "function");
|
|
15
|
+
if (missing !== void 0) throw new Error(`notifications({ historyStore }): the history store has no ${missing} method`);
|
|
16
|
+
return store;
|
|
17
|
+
};
|
|
18
|
+
/** The most entries `list` gives at once. */
|
|
19
|
+
const maxHistoryPage = 50;
|
|
20
|
+
/** The value, refused when empty: no store call may be read as "every user". */
|
|
21
|
+
const requiredText = (name, value) => {
|
|
22
|
+
if (typeof value !== "string" || value === "") throw new Error(`The history store needs a ${name}`);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
/** The time, refused when it is no valid `Date`. */
|
|
26
|
+
const requiredTime = (name, value) => {
|
|
27
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new Error(`The history store needs a ${name}`);
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
/** The page asked for, refused unless its limit is a whole number from 1 to `maxHistoryPage` and its position whole. */
|
|
31
|
+
const requiredPage = (page) => {
|
|
32
|
+
if (typeof page !== "object" || page === null || !Number.isInteger(page.limit) || page.limit < 1 || page.limit > 50) throw new Error(`The history store needs a page limit from 1 to 50`);
|
|
33
|
+
if (page.before === void 0) return page;
|
|
34
|
+
requiredTime("position time", page.before.createdAt);
|
|
35
|
+
requiredText("position id", page.before.id);
|
|
36
|
+
return page;
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { checkHistoryStore, maxHistoryPage, maxUnreadCount, requiredPage, requiredText, requiredTime };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/notifications/history-url.ts
|
|
2
|
+
const ownPath = /^\/(?![/\\])[^\s\u0000-\u001f\u007f-\u009f]*$/;
|
|
3
|
+
const absolute = /^https?:\/\//i;
|
|
4
|
+
/**
|
|
5
|
+
* The url an entry of the history may lead to, as an answer shows it, or `null`: a path on the app, or an absolute
|
|
6
|
+
* `http` or `https` URL without user info whose origin is exactly one of `origins`, the app's own, written as the URL
|
|
7
|
+
* parser writes it. Anything else, another site, `javascript:`, `data:` or a scheme-relative URL, is `null`.
|
|
8
|
+
*/
|
|
9
|
+
const historyUrl = (url, origins) => {
|
|
10
|
+
if (url === null || url.length > 2048) return null;
|
|
11
|
+
if (ownPath.test(url)) return url;
|
|
12
|
+
if (!absolute.test(url) || !URL.canParse(url) || /[\s\u0000-\u001f\u007f-\u009f]/.test(url)) return null;
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
if (parsed.username !== "" || parsed.password !== "") return null;
|
|
15
|
+
return origins.some((origin) => URL.canParse(origin) && new URL(origin).origin === parsed.origin) ? parsed.href : null;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { historyUrl };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { JsonObject } from "./json-value.js";
|
|
2
|
+
//#region src/notifications/history.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A notification as the history keeps it for its user's inbox: what a panel shows of it. `title` and `body` are plain
|
|
5
|
+
* text; `url` is where it leads; `data` is the app's own JSON.
|
|
6
|
+
*/
|
|
7
|
+
export type NewNotification = {
|
|
8
|
+
title: string;
|
|
9
|
+
body: string | null;
|
|
10
|
+
url: string | null;
|
|
11
|
+
data: JsonObject | null;
|
|
12
|
+
};
|
|
13
|
+
/** A kept notification: whose it is, its id, when it was recorded and when its user read it, `null` while unread. */
|
|
14
|
+
export type StoredNotification = NewNotification & {
|
|
15
|
+
id: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
readAt: Date | null;
|
|
19
|
+
};
|
|
20
|
+
/** Where a page of the history ends: the last entry's creation time and id. The next page starts after it. */
|
|
21
|
+
export type HistoryPosition = {
|
|
22
|
+
createdAt: Date;
|
|
23
|
+
id: string;
|
|
24
|
+
};
|
|
25
|
+
/** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
|
|
26
|
+
export declare const historyIdPattern: RegExp;
|
|
27
|
+
/** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
|
|
28
|
+
export declare const historyLimits: {
|
|
29
|
+
readonly title: 256;
|
|
30
|
+
readonly body: 2048;
|
|
31
|
+
readonly url: 2048;
|
|
32
|
+
readonly dataBytes: 4096;
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isJsonObject, jsonProblem } from "./json-value.js";
|
|
2
|
+
//#region src/notifications/history.ts
|
|
3
|
+
/** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
|
|
4
|
+
const historyIdPattern = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
/** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
|
|
6
|
+
const historyLimits = {
|
|
7
|
+
title: 256,
|
|
8
|
+
body: 2048,
|
|
9
|
+
url: 2048,
|
|
10
|
+
dataBytes: 4096
|
|
11
|
+
};
|
|
12
|
+
const noControls = /^[^\u0000-\u001f\u007f]*$/;
|
|
13
|
+
const noControlsButLines = /^[^\u0000-\u0008\u000b-\u001f\u007f]*$/;
|
|
14
|
+
const text = (value, max, pattern) => typeof value === "string" && value.length <= max && pattern.test(value);
|
|
15
|
+
const notificationProblem = (notification) => {
|
|
16
|
+
if (typeof notification !== "object" || notification === null) return "notification";
|
|
17
|
+
if (!text(notification.title, historyLimits.title, noControls) || notification.title === "") return "title";
|
|
18
|
+
if (notification.body !== null && !text(notification.body, historyLimits.body, noControlsButLines)) return "body";
|
|
19
|
+
if (notification.url !== null && !text(notification.url, historyLimits.url, noControls)) return "url";
|
|
20
|
+
if (notification.data === null) return void 0;
|
|
21
|
+
if (!isJsonObject(notification.data) || jsonProblem(notification.data, "") !== void 0) return "data";
|
|
22
|
+
if (Buffer.byteLength(JSON.stringify(notification.data)) > historyLimits.dataBytes) return "data";
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The notification as a new object with its four fields alone, or an error naming the first field at fault, never its
|
|
26
|
+
* value: a title of 1 to 256 characters, a body and a url of at most 2048, without control characters, and `data`
|
|
27
|
+
* plain JSON of at most 4096 bytes. Every history store records only what this accepts.
|
|
28
|
+
*/
|
|
29
|
+
const checkedNotification = (notification) => {
|
|
30
|
+
const problem = notificationProblem(notification);
|
|
31
|
+
if (problem !== void 0) throw new Error(`The history cannot keep this notification: its ${problem} is invalid`);
|
|
32
|
+
const { title, body, url, data } = notification;
|
|
33
|
+
return {
|
|
34
|
+
title,
|
|
35
|
+
body,
|
|
36
|
+
url,
|
|
37
|
+
data: data === null ? null : structuredClone(data)
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { checkedNotification, historyIdPattern, historyLimits };
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { DeviceStore } from "./device-store.js";
|
|
2
|
+
import { HistoryStore } from "./history-store.js";
|
|
2
3
|
import { PushSender } from "./sender.js";
|
|
3
4
|
//#region src/notifications/options.d.ts
|
|
4
5
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
5
6
|
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
|
|
6
7
|
export type DeviceColumn = (typeof deviceColumns)[number];
|
|
8
|
+
/** The columns of the history table, by the name Protobase gives them. */
|
|
9
|
+
export declare const historyColumns: readonly ["id", "user_id", "title", "body", "url", "data", "created_at", "read_at"];
|
|
10
|
+
export type HistoryColumn = (typeof historyColumns)[number];
|
|
7
11
|
/** Notifications, turned on with `extends: [notifications()]`. */
|
|
8
12
|
export type NotificationsOptions = {
|
|
9
13
|
/** The bell in the top bar; default true. `false` keeps only the backend. */
|
|
@@ -17,6 +21,13 @@ export type NotificationsOptions = {
|
|
|
17
21
|
};
|
|
18
22
|
/** Keeps the devices somewhere else than the devices table, which is then not needed. */
|
|
19
23
|
store?: DeviceStore;
|
|
24
|
+
/** The history table: its name, default `history`, and its columns named otherwise than Protobase names them. */
|
|
25
|
+
history?: {
|
|
26
|
+
table?: string;
|
|
27
|
+
columns?: Partial<Record<HistoryColumn, string>>;
|
|
28
|
+
};
|
|
29
|
+
/** Keeps each user's notification history somewhere else than the history table, which is then not needed. */
|
|
30
|
+
historyStore?: HistoryStore;
|
|
20
31
|
/**
|
|
21
32
|
* Deliver notifications in place of the built-in senders, such as senders from a package; at most one for each kind
|
|
22
33
|
* of device. By default the built-in ones whose environment is set: Web Push with the `PROTOBASE_VAPID_*` variables.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkDeviceStore } from "./device-store.js";
|
|
2
|
+
import { checkHistoryStore } from "./history-store.js";
|
|
2
3
|
import { checkSenders } from "./sender.js";
|
|
3
4
|
//#region src/notifications/options.ts
|
|
4
5
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
@@ -15,31 +16,49 @@ const deviceColumns = [
|
|
|
15
16
|
"created_at",
|
|
16
17
|
"last_seen_at"
|
|
17
18
|
];
|
|
19
|
+
/** The columns of the history table, by the name Protobase gives them. */
|
|
20
|
+
const historyColumns = [
|
|
21
|
+
"id",
|
|
22
|
+
"user_id",
|
|
23
|
+
"title",
|
|
24
|
+
"body",
|
|
25
|
+
"url",
|
|
26
|
+
"data",
|
|
27
|
+
"created_at",
|
|
28
|
+
"read_at"
|
|
29
|
+
];
|
|
18
30
|
const namePattern = /^[a-z_][a-z0-9_]*$/;
|
|
19
31
|
const maxNameBytes = 63;
|
|
20
32
|
const checkedName = (option, name) => {
|
|
21
33
|
if (typeof name !== "string" || !namePattern.test(name) || Buffer.byteLength(name) > maxNameBytes) throw new Error(`notifications ${option} is not a valid name: use lowercase letters, digits and "_", at most ${maxNameBytes} characters`);
|
|
22
34
|
return name;
|
|
23
35
|
};
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (unknown !== void 0) throw new Error(`notifications devices.columns names "${unknown}", which is not a column of the devices table: ${deviceColumns.join(", ")}`);
|
|
29
|
-
const columns = Object.fromEntries(deviceColumns.map((column) => [column, checkedName(`devices.columns.${column}`, given[column] ?? column)]));
|
|
30
|
-
return {
|
|
31
|
-
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
32
|
-
table: checkedName("devices.table", options.devices?.table ?? "devices"),
|
|
33
|
-
columns
|
|
34
|
-
};
|
|
36
|
+
const columnNames = (table, all, given = {}) => {
|
|
37
|
+
const unknown = Object.keys(given).find((name) => !all.includes(name));
|
|
38
|
+
if (unknown !== void 0) throw new Error(`notifications ${table}.columns names "${unknown}", which is not a column of the ${table} table: ${all.join(", ")}`);
|
|
39
|
+
return Object.fromEntries(all.map((column) => [column, checkedName(`${table}.columns.${column}`, given[column] ?? column)]));
|
|
35
40
|
};
|
|
36
|
-
/** The
|
|
41
|
+
/** The devices table's names, checked: a bad or unknown name is an error naming its option. */
|
|
42
|
+
const notificationsNames = (options = {}) => ({
|
|
43
|
+
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
44
|
+
table: checkedName("devices.table", options.devices?.table ?? "devices"),
|
|
45
|
+
columns: columnNames("devices", deviceColumns, options.devices?.columns)
|
|
46
|
+
});
|
|
47
|
+
/** The history table's names, checked as the devices table's are. */
|
|
48
|
+
const historyNames = (options = {}) => ({
|
|
49
|
+
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
50
|
+
table: checkedName("history.table", options.history?.table ?? "history"),
|
|
51
|
+
columns: columnNames("history", historyColumns, options.history?.columns)
|
|
52
|
+
});
|
|
53
|
+
/** The options, checked as `notifications()` takes them: the names, given stores' methods and the senders given. */
|
|
37
54
|
const checkNotificationsOptions = (options) => {
|
|
38
55
|
if (options.ui !== void 0 && typeof options.ui !== "boolean") throw new Error("notifications ui is true or false");
|
|
39
56
|
notificationsNames(options);
|
|
57
|
+
historyNames(options);
|
|
40
58
|
if (options.store !== void 0) checkDeviceStore(options.store);
|
|
59
|
+
if (options.historyStore !== void 0) checkHistoryStore(options.historyStore);
|
|
41
60
|
if (options.senders !== void 0) checkSenders(options.senders);
|
|
42
61
|
return options;
|
|
43
62
|
};
|
|
44
63
|
//#endregion
|
|
45
|
-
export { checkNotificationsOptions, deviceColumns, notificationsNames };
|
|
64
|
+
export { checkNotificationsOptions, deviceColumns, historyColumns, historyNames, notificationsNames };
|
|
@@ -3,4 +3,14 @@
|
|
|
3
3
|
export declare const deviceIdParam = "id";
|
|
4
4
|
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
5
5
|
export declare const devicesPath: (systemPath: string, id?: string) => string;
|
|
6
|
+
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
7
|
+
export declare const historyIdParam = "id";
|
|
8
|
+
/**
|
|
9
|
+
* The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
|
|
10
|
+
* unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
|
|
11
|
+
* path, any but letters, digits, `_` and `-`, is refused.
|
|
12
|
+
*/
|
|
13
|
+
export declare const historyPath: (systemPath: string, part?: "unread" | "read" | {
|
|
14
|
+
read: string;
|
|
15
|
+
}) => string;
|
|
6
16
|
//#endregion
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
import { historyIdPattern } from "./history.js";
|
|
1
2
|
//#region src/notifications/paths.ts
|
|
2
3
|
/** The name of the device id in a device's path. */
|
|
3
4
|
const deviceIdParam = "id";
|
|
4
5
|
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
5
6
|
const devicesPath = (systemPath, id) => `${systemPath}/notifications/devices${id === void 0 ? "" : `/${encodeURIComponent(id)}`}`;
|
|
7
|
+
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
8
|
+
const historyIdParam = "id";
|
|
9
|
+
/**
|
|
10
|
+
* The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
|
|
11
|
+
* unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
|
|
12
|
+
* path, any but letters, digits, `_` and `-`, is refused.
|
|
13
|
+
*/
|
|
14
|
+
const historyPath = (systemPath, part) => {
|
|
15
|
+
const base = `${systemPath}/notifications/history`;
|
|
16
|
+
if (part === void 0) return base;
|
|
17
|
+
if (typeof part === "string") return `${base}/${part}`;
|
|
18
|
+
if (!historyIdPattern.test(part.read)) throw new Error("A history entry id is letters, digits, \"_\" and \"-\" only");
|
|
19
|
+
return `${base}/${part.read}/read`;
|
|
20
|
+
};
|
|
6
21
|
//#endregion
|
|
7
|
-
export { deviceIdParam, devicesPath };
|
|
22
|
+
export { deviceIdParam, devicesPath, historyIdParam, historyPath };
|
|
@@ -4,8 +4,10 @@ import { ProjectConfig } from "../project-config.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
6
6
|
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
7
|
-
* through which a signed-in user registers and removes their own (`devicesPath`). It
|
|
8
|
-
*
|
|
7
|
+
* through which a signed-in user registers and removes their own (`devicesPath`). It keeps each user's inbox
|
|
8
|
+
* notifications in the `notifications.history` table or the `historyStore` given, and serves the endpoints through which
|
|
9
|
+
* a signed-in user lists theirs and marks them read (`historyPath`). It delivers through `senders`, by default the
|
|
10
|
+
* built-in ones whose environment is set (see `notificationSenders`).
|
|
9
11
|
*/
|
|
10
12
|
export declare const notifications: (options?: NotificationsOptions) => ProjectConfig;
|
|
11
13
|
//#endregion
|
|
@@ -3,8 +3,10 @@ import { checkNotificationsOptions } from "./options.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
5
5
|
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
6
|
-
* through which a signed-in user registers and removes their own (`devicesPath`). It
|
|
7
|
-
*
|
|
6
|
+
* through which a signed-in user registers and removes their own (`devicesPath`). It keeps each user's inbox
|
|
7
|
+
* notifications in the `notifications.history` table or the `historyStore` given, and serves the endpoints through which
|
|
8
|
+
* a signed-in user lists theirs and marks them read (`historyPath`). It delivers through `senders`, by default the
|
|
9
|
+
* built-in ones whose environment is set (see `notificationSenders`).
|
|
8
10
|
*/
|
|
9
11
|
const notifications = (options = {}) => ({
|
|
10
12
|
name: "protobase-notifications",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { HistoryStore } from "./history-store.js";
|
|
2
|
+
import { NotificationsOptions } from "./options.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/notifications/postgres-history-store.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A database error of the history store, holding its SQLSTATE alone: the driver's own carries the failing row in its
|
|
7
|
+
* detail, which must never reach a log.
|
|
8
|
+
*/
|
|
9
|
+
export declare class HistoryStoreError extends Error {
|
|
10
|
+
readonly sqlState: string;
|
|
11
|
+
constructor(sqlState: string);
|
|
12
|
+
}
|
|
13
|
+
/** The default history store: the history table `protobase migration` writes, under the names the options give. */
|
|
14
|
+
export declare const postgresHistoryStore: (db: Db, options?: NotificationsOptions) => HistoryStore;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
2
|
+
import { requiredPage, requiredText, requiredTime } from "./history-store.js";
|
|
3
|
+
import { historyColumns, historyNames } from "./options.js";
|
|
4
|
+
import { checkedNotification } from "./history.js";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
//#region src/notifications/postgres-history-store.ts
|
|
7
|
+
/**
|
|
8
|
+
* A database error of the history store, holding its SQLSTATE alone: the driver's own carries the failing row in its
|
|
9
|
+
* detail, which must never reach a log.
|
|
10
|
+
*/
|
|
11
|
+
var HistoryStoreError = class extends Error {
|
|
12
|
+
sqlState;
|
|
13
|
+
constructor(sqlState) {
|
|
14
|
+
super(`The history store's query failed (SQLSTATE ${sqlState})`);
|
|
15
|
+
this.sqlState = sqlState;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
const entryOf = (row) => ({
|
|
20
|
+
id: String(row.id),
|
|
21
|
+
userId: String(row.user_id),
|
|
22
|
+
title: row.title,
|
|
23
|
+
body: row.body,
|
|
24
|
+
url: row.url,
|
|
25
|
+
data: row.data,
|
|
26
|
+
createdAt: new Date(row.created_at),
|
|
27
|
+
readAt: row.read_at === null ? null : new Date(row.read_at)
|
|
28
|
+
});
|
|
29
|
+
/** The default history store: the history table `protobase migration` writes, under the names the options give. */
|
|
30
|
+
const postgresHistoryStore = (db, options = {}) => {
|
|
31
|
+
const { schema, table, columns } = historyNames(options);
|
|
32
|
+
const history = sql.id(schema, table);
|
|
33
|
+
const column = (name) => sql.id(columns[name]);
|
|
34
|
+
const selected = sql.join(historyColumns.map((name) => sql`${column(name)} as ${sql.id(name)}`));
|
|
35
|
+
const execute = (query) => query.execute(db).catch((error) => {
|
|
36
|
+
if (isDatabaseError(error)) throw new HistoryStoreError(error.code);
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
const rows = async (query) => (await execute(query)).rows.map(entryOf);
|
|
40
|
+
const unread = (userId) => sql`${column("user_id")} = ${userId} and ${column("read_at")} is null`;
|
|
41
|
+
return {
|
|
42
|
+
record: async (userId, notification) => {
|
|
43
|
+
requiredText("user id", userId);
|
|
44
|
+
const { title, body, url, data } = checkedNotification(notification);
|
|
45
|
+
const values = {
|
|
46
|
+
id: crypto.randomUUID(),
|
|
47
|
+
user_id: userId,
|
|
48
|
+
title,
|
|
49
|
+
body,
|
|
50
|
+
url,
|
|
51
|
+
data: data === null ? null : JSON.stringify(data),
|
|
52
|
+
created_at: /* @__PURE__ */ new Date(),
|
|
53
|
+
read_at: null
|
|
54
|
+
};
|
|
55
|
+
const names = sql.join(historyColumns.map(column));
|
|
56
|
+
const given = sql.join(historyColumns.map((name) => name === "data" ? sql`${values.data}::jsonb` : sql`${values[name]}`));
|
|
57
|
+
const [stored] = await rows(sql`insert into ${history} (${names}) values (${given}) returning ${selected}`);
|
|
58
|
+
return stored;
|
|
59
|
+
},
|
|
60
|
+
list: async (userId, page) => {
|
|
61
|
+
requiredText("user id", userId);
|
|
62
|
+
const { before, limit } = requiredPage(page);
|
|
63
|
+
const after = before && uuidPattern.test(before.id) ? sql`and (${column("created_at")}, ${column("id")}) < (${before.createdAt}, ${before.id}::uuid)` : before ? sql`and ${column("created_at")} < ${before.createdAt}` : sql``;
|
|
64
|
+
return rows(sql`select ${selected} from ${history} where ${column("user_id")} = ${userId} ${after}
|
|
65
|
+
order by ${column("created_at")} desc, ${column("id")} desc limit ${limit}`);
|
|
66
|
+
},
|
|
67
|
+
countUnread: async (userId) => {
|
|
68
|
+
requiredText("user id", userId);
|
|
69
|
+
return (await execute(sql`select count(*)::int as count from (select from ${history} where ${unread(userId)} limit ${100}) as unread`)).rows[0].count;
|
|
70
|
+
},
|
|
71
|
+
markRead: async (userId, id) => {
|
|
72
|
+
requiredText("user id", userId);
|
|
73
|
+
requiredText("id", id);
|
|
74
|
+
if (!uuidPattern.test(id)) return false;
|
|
75
|
+
return (await execute(sql`with found as (select ${column("id")} from ${history} where ${column("user_id")} = ${userId} and ${column("id")} = ${id}::uuid),
|
|
76
|
+
marked as (update ${history} set ${column("read_at")} = ${/* @__PURE__ */ new Date()} where ${column("id")} in (select ${column("id")} from found) and ${column("read_at")} is null)
|
|
77
|
+
select exists (select from found) as found`)).rows[0].found;
|
|
78
|
+
},
|
|
79
|
+
markAllRead: async (userId, upTo) => {
|
|
80
|
+
requiredText("user id", userId);
|
|
81
|
+
requiredTime("time", upTo);
|
|
82
|
+
await execute(sql`update ${history} set ${column("read_at")} = ${/* @__PURE__ */ new Date()} where ${unread(userId)} and ${column("created_at")} <= ${upTo}`);
|
|
83
|
+
},
|
|
84
|
+
removeAllForUser: async (userId) => {
|
|
85
|
+
requiredText("user id", userId);
|
|
86
|
+
await execute(sql`delete from ${history} where ${column("user_id")} = ${userId}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { HistoryStoreError, postgresHistoryStore };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { ownSignInUser } from "./caller.js";
|
|
2
3
|
import "./paths.js";
|
|
3
|
-
import { ownDeviceUser } from "./caller.js";
|
|
4
4
|
import { deviceInput, maxDeviceBodyBytes } from "./device-input.js";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { bodyLimit } from "hono/body-limit";
|
|
@@ -8,7 +8,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
8
8
|
/** The most devices one user keeps: registering one more drops their least recently seen. */
|
|
9
9
|
const maxDevicesPerUser = 20;
|
|
10
10
|
const ownSignIn = async (c, next) => {
|
|
11
|
-
c.set("deviceUser",
|
|
11
|
+
c.set("deviceUser", ownSignInUser(c.get("session")));
|
|
12
12
|
await next();
|
|
13
13
|
};
|
|
14
14
|
const jsonOnly = async (c, next) => {
|
|
@@ -3,15 +3,11 @@ import { NotificationsOptions } from "./options.js";
|
|
|
3
3
|
import { Db } from "@protobase/query";
|
|
4
4
|
//#region src/notifications/schema.d.ts
|
|
5
5
|
/**
|
|
6
|
-
* The SQL that adds the
|
|
7
|
-
* `auth`,
|
|
8
|
-
* app's own
|
|
6
|
+
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
7
|
+
* give; with `auth`, their user columns point at auth's users table. Each table is left out when it exists or a store
|
|
8
|
+
* of the app's own replaces it; `undefined` when none is left.
|
|
9
9
|
*/
|
|
10
10
|
export declare const notificationsMigration: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<string | undefined>;
|
|
11
|
-
/**
|
|
12
|
-
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
13
|
-
* its configured name, a unique index on the token alone, which moving a device relies on, and with `auth` a foreign key
|
|
14
|
-
* from the user column that deletes a user's devices with the user. Nothing to check with a store of the app's own.
|
|
15
|
-
*/
|
|
11
|
+
/** Refuses to start while the devices or the history table lacks what `notifications()` needs, naming it. */
|
|
16
12
|
export declare const checkNotificationsSchema: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<void>;
|
|
17
13
|
//#endregion
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
|
-
import { notificationsNames } from "./options.js";
|
|
2
|
+
import { historyNames, notificationsNames } from "./options.js";
|
|
3
3
|
import { storeSchema } from "../better-auth/auth-tables.js";
|
|
4
4
|
import { devicesTableSql, qualified } from "./schema-sql.js";
|
|
5
|
+
import { historyTableSql } from "./history-sql.js";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
//#region src/notifications/schema.ts
|
|
7
8
|
/** Auth's users table, as the store names it: devices point at it. */
|
|
@@ -17,43 +18,65 @@ const userIdType = async (db, users) => {
|
|
|
17
18
|
return rows[0].type;
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
20
|
-
* The SQL that adds the
|
|
21
|
-
* `auth`,
|
|
22
|
-
* app's own
|
|
21
|
+
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
22
|
+
* give; with `auth`, their user columns point at auth's users table. Each table is left out when it exists or a store
|
|
23
|
+
* of the app's own replaces it; `undefined` when none is left.
|
|
23
24
|
*/
|
|
24
25
|
const notificationsMigration = async (db, options = {}, auth) => {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const devices = !options.store && notificationsNames(options);
|
|
27
|
+
const history = !options.historyStore && historyNames(options);
|
|
28
|
+
const missing = async (names) => names && await regclass(db, qualified(names)) === null;
|
|
29
|
+
const [needsDevices, needsHistory] = [await missing(devices), await missing(history)];
|
|
30
|
+
if (!needsDevices && !needsHistory) return void 0;
|
|
28
31
|
const users = auth && authUsersTable(auth);
|
|
29
32
|
const user = users ? {
|
|
30
33
|
type: await userIdType(db, users),
|
|
31
34
|
references: `${qualified(users)} (id)`
|
|
32
35
|
} : { type: "text" };
|
|
33
|
-
|
|
36
|
+
const statements = [...needsDevices && devices ? devicesTableSql(devices, user) : [], ...needsHistory && history ? historyTableSql(history, user) : []];
|
|
37
|
+
return `${[...new Set(statements)].join(";\n\n")};`;
|
|
34
38
|
};
|
|
35
39
|
const columnsOf = async (db, oid) => (await sql`select attname as name, attnum as num from pg_attribute where attrelid = ${oid} and attnum > 0 and not attisdropped`.execute(db)).rows;
|
|
36
40
|
const uniqueOn = async (db, oid, num) => (await sql`select exists (select from pg_index where indrelid = ${oid} and indisunique and indpred is null and indexprs is null and indnatts = 1 and indkey[0] = ${num}) as found`.execute(db)).rows[0].found;
|
|
37
41
|
const cascadesFrom = async (db, oid, num, users) => (await sql`select exists (select from pg_constraint where conrelid = ${oid} and contype = 'f' and confdeltype = 'c'
|
|
38
42
|
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
43
|
+
const missingTable = (what, table) => /* @__PURE__ */ new Error(`The ${what} table ${table} of notifications() is missing. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
39
44
|
/**
|
|
40
45
|
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
41
46
|
* its configured name, a unique index on the token alone, which moving a device relies on, and with `auth` a foreign key
|
|
42
47
|
* from the user column that deletes a user's devices with the user. Nothing to check with a store of the app's own.
|
|
43
48
|
*/
|
|
44
|
-
const
|
|
49
|
+
const checkDevicesTable = async (db, options, users) => {
|
|
45
50
|
if (options.store) return;
|
|
46
51
|
const names = notificationsNames(options);
|
|
47
52
|
const devices = qualified(names);
|
|
48
53
|
const oid = await regclass(db, devices);
|
|
49
|
-
if (oid === null) throw
|
|
54
|
+
if (oid === null) throw missingTable("devices", devices);
|
|
50
55
|
const found = await columnsOf(db, oid);
|
|
51
56
|
const numOf = (column) => found.find((row) => row.name === names.columns[column])?.num;
|
|
52
57
|
const missing = Object.keys(names.columns).find((column) => numOf(column) === void 0);
|
|
53
58
|
if (missing !== void 0) throw new Error(`The devices table ${devices} has no column "${names.columns[missing]}": add it, or name the table's own with notifications({ devices: { columns: { ${missing}: ... } } })`);
|
|
54
59
|
if (!await uniqueOn(db, oid, numOf("token"))) throw new Error(`The devices table ${devices} needs a unique index on its token column "${names.columns.token}" alone, which moving a device to the user who registers it relies on`);
|
|
55
|
-
const users = auth && authUsersTable(auth);
|
|
56
60
|
if (users && !await cascadesFrom(db, oid, numOf("user_id"), users)) throw new Error(`The devices table ${devices} needs a foreign key from "${names.columns.user_id}" to ${qualified(users)} with on delete cascade, so a deleted user's devices go with them`);
|
|
57
61
|
};
|
|
62
|
+
/** Refuses to start while the history table lacks its table, a column or, with `auth`, the cascading foreign key. */
|
|
63
|
+
const checkHistoryTable = async (db, options, users) => {
|
|
64
|
+
if (options.historyStore) return;
|
|
65
|
+
const names = historyNames(options);
|
|
66
|
+
const history = qualified(names);
|
|
67
|
+
const oid = await regclass(db, history);
|
|
68
|
+
if (oid === null) throw missingTable("history", history);
|
|
69
|
+
const found = await columnsOf(db, oid);
|
|
70
|
+
const numOf = (column) => found.find((row) => row.name === names.columns[column])?.num;
|
|
71
|
+
const missing = Object.keys(names.columns).find((column) => numOf(column) === void 0);
|
|
72
|
+
if (missing !== void 0) throw new Error(`The history table ${history} has no column "${names.columns[missing]}": add it, or name the table's own with notifications({ history: { columns: { ${missing}: ... } } })`);
|
|
73
|
+
if (users && !await cascadesFrom(db, oid, numOf("user_id"), users)) throw new Error(`The history table ${history} needs a foreign key from "${names.columns.user_id}" to ${qualified(users)} with on delete cascade, so a deleted user's history goes with them`);
|
|
74
|
+
};
|
|
75
|
+
/** Refuses to start while the devices or the history table lacks what `notifications()` needs, naming it. */
|
|
76
|
+
const checkNotificationsSchema = async (db, options = {}, auth) => {
|
|
77
|
+
const users = auth && authUsersTable(auth);
|
|
78
|
+
await checkDevicesTable(db, options, users);
|
|
79
|
+
await checkHistoryTable(db, options, users);
|
|
80
|
+
};
|
|
58
81
|
//#endregion
|
|
59
82
|
export { authUsersTable, checkNotificationsSchema, notificationsMigration };
|
package/dist/package.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.18",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"@better-auth/passkey": "^1.7.12",
|
|
37
37
|
"@better-auth/sso": "^1.7.12",
|
|
38
38
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
39
|
-
"@protobase/layout": "0.45.
|
|
40
|
-
"@protobase/query": "0.45.
|
|
41
|
-
"@protobase/schema": "0.45.
|
|
39
|
+
"@protobase/layout": "0.45.18",
|
|
40
|
+
"@protobase/query": "0.45.18",
|
|
41
|
+
"@protobase/schema": "0.45.18",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|