@k2b/cloud 0.43.0 → 0.45.0
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/package.json +11 -2
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +158 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +7 -5
- package/src/_internal/define-app.ts +57 -6
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +11 -1
- package/src/_internal/registry.ts +8 -1
- package/src/_internal/runtime-context.ts +8 -1
- package/src/access/AppAccessEditor.tsx +2 -0
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +20 -5
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/PrincipalPicker.tsx +14 -4
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-messages.ts +6 -0
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +7 -0
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +10 -6
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +40 -25
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +12 -5
- package/src/ai/inference-calls.ts +5 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api.ts +26 -9
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +58 -47
- package/src/ai/projects.ts +110 -88
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +55 -6
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider.ts +1 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +4 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +9 -3
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +47 -48
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +21 -44
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +27 -22
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +22 -3
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +35 -5
- package/src/api/capabilities.ts +28 -4
- package/src/api/index.ts +7 -1
- package/src/api/mcp.ts +175 -2
- package/src/api/me.ts +6 -4
- package/src/api/pwa-phone.ts +75 -26
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/app-pwa/contracts.ts +4 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +1 -0
- package/src/app-pwa/service.ts +91 -0
- package/src/app-pwa/transport.ts +22 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/capabilities/server-invoke.ts +2 -11
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +5 -2
- package/src/cli/apps.ts +89 -26
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +26 -2
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +21 -5
- package/src/contracts/capabilities.ts +143 -3
- package/src/contracts/index.ts +2 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/pwa.ts +17 -1
- package/src/contracts/registry.ts +6 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +6 -1
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +1 -1
- package/src/server/api/respond.ts +17 -4
- package/src/server/index.ts +2 -0
- package/src/server/middleware/app-access.ts +1 -1
- package/src/server/middleware/auth.ts +21 -7
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +2 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +31 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +36 -13
- package/src/services/accounts/entities.ts +43 -19
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +19 -14
- package/src/services/accounts/identities.ts +70 -11
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +69 -4
- package/src/services/accounts/users.ts +45 -14
- package/src/services/app-access.ts +25 -7
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/ledger.ts +28 -10
- package/src/services/geo/service.ts +43 -24
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/invocation-authority.ts +4 -0
- package/src/services/identity/invocation-token.ts +238 -56
- package/src/services/identity/issue-invocation.ts +6 -1
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +13 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +20 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/session/index.ts +26 -3
- package/src/services/settings/core-settings.ts +71 -43
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/email-html.ts +1 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +433 -65
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/text-diff.ts +75 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +9 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +2 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +44 -7
- package/src/ssr/Layout.tsx +9 -7
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/PwaLayout.tsx +16 -1
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -31
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/index.ts +8 -0
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +11 -1
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/pwa-messages.ts +2 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +3 -1
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +134 -34
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { TopicDeadLetter, TopicInventoryEntry } from "@k2b/sync";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import { resolveCapabilityManifestPresentation } from "../../_internal/capabilities";
|
|
4
|
+
import type { AppEventEnvelope } from "../../contracts/app-events";
|
|
5
|
+
import type { CapabilityEventCatalogEntry } from "../../contracts/capabilities";
|
|
6
|
+
import type { CapabilityRegistryEntry } from "../../contracts/registry";
|
|
7
|
+
import { type AuditActor, audit } from "../audit";
|
|
8
|
+
import { toPgTextArray } from "../postgres";
|
|
9
|
+
import type { StoredDeclaration } from "./declarations";
|
|
10
|
+
|
|
11
|
+
type Dependencies = {
|
|
12
|
+
registry: () => Promise<CapabilityRegistryEntry[]>;
|
|
13
|
+
deadLetters: (after?: string) => Promise<TopicDeadLetter<AppEventEnvelope>[]>;
|
|
14
|
+
streams: () => AsyncIterable<Pick<TopicInventoryEntry, "id" | "firstSequence">>;
|
|
15
|
+
};
|
|
16
|
+
export function createEventAdministration(db: SQL, dependencies: Dependencies) {
|
|
17
|
+
const catalog = async (locale = "en"): Promise<{ events: CapabilityEventCatalogEntry[] }> => {
|
|
18
|
+
const [entries, declarations, storedSubscriptions] = await Promise.all([
|
|
19
|
+
dependencies.registry(),
|
|
20
|
+
db<StoredDeclaration[]>`SELECT * FROM events.app_declarations WHERE retired_at IS NULL ORDER BY type, version`,
|
|
21
|
+
db<
|
|
22
|
+
{
|
|
23
|
+
appId: string;
|
|
24
|
+
subscriptionId: string;
|
|
25
|
+
event_type: string;
|
|
26
|
+
version: number;
|
|
27
|
+
state: "active" | "blocked" | "cancelled";
|
|
28
|
+
reason: string | null;
|
|
29
|
+
firstSeenAt: Date;
|
|
30
|
+
}[]
|
|
31
|
+
>`
|
|
32
|
+
SELECT app_id AS "appId", id AS "subscriptionId", event_type, version, state, reason, first_seen_at AS "firstSeenAt"
|
|
33
|
+
FROM events.app_subscriptions ORDER BY id
|
|
34
|
+
`,
|
|
35
|
+
]);
|
|
36
|
+
return {
|
|
37
|
+
events: declarations.map((declaration) => {
|
|
38
|
+
const entry = entries.find(({ appId }) => appId === declaration.app_id);
|
|
39
|
+
const presentation = entry
|
|
40
|
+
? resolveCapabilityManifestPresentation(entry.manifest, entry.presentation, locale).events?.find(
|
|
41
|
+
(event) => `${entry.appId}.${event.localId}` === declaration.type && event.version === declaration.version,
|
|
42
|
+
)
|
|
43
|
+
: undefined;
|
|
44
|
+
return {
|
|
45
|
+
type: declaration.type,
|
|
46
|
+
version: declaration.version,
|
|
47
|
+
title: presentation?.title ?? declaration.title,
|
|
48
|
+
description: presentation?.description ?? declaration.description,
|
|
49
|
+
subject: `${declaration.app_id}.${declaration.subject}`,
|
|
50
|
+
place: declaration.place ? `${declaration.app_id}.${declaration.place}` : null,
|
|
51
|
+
automation: declaration.automation,
|
|
52
|
+
dataSchema: declaration.data_schema,
|
|
53
|
+
subscribers: storedSubscriptions
|
|
54
|
+
.filter((subscription) => subscription.event_type === declaration.type && subscription.version === declaration.version)
|
|
55
|
+
.map(({ event_type: _eventType, version: _version, firstSeenAt, ...subscription }) => ({
|
|
56
|
+
...subscription,
|
|
57
|
+
firstSeenAt: firstSeenAt.toISOString(),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const subscriptions = async (app?: string) => {
|
|
64
|
+
return {
|
|
65
|
+
items: await db`
|
|
66
|
+
SELECT subscription.id, subscription.app_id AS "appId", subscription.event_type AS "eventType", subscription.version,
|
|
67
|
+
subscription.state, subscription.reason, subscription.first_seen_at AS "firstSeenAt",
|
|
68
|
+
(SELECT jsonb_object_agg(state, count) FROM (
|
|
69
|
+
SELECT state, count(*)::int AS count FROM events.app_deliveries
|
|
70
|
+
WHERE subscription_id = subscription.id GROUP BY state
|
|
71
|
+
) counts) AS counts
|
|
72
|
+
FROM events.app_subscriptions subscription
|
|
73
|
+
WHERE (${app ?? null}::text IS NULL OR subscription.app_id = ${app ?? null}) ORDER BY subscription.id
|
|
74
|
+
`,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const deliveries = async (filter: { app?: string; state?: string; eventType?: string; offset?: number; limit?: number } = {}) => {
|
|
78
|
+
const limit = Math.max(1, Math.min(100, filter.limit ?? 50));
|
|
79
|
+
const offset = Math.max(0, filter.offset ?? 0);
|
|
80
|
+
const rows = await db`
|
|
81
|
+
SELECT delivery.id, delivery.event_id AS "eventId", delivery.subscription_id AS "subscriptionId",
|
|
82
|
+
subscription.app_id AS "appId", event.type AS "eventType", delivery.state, delivery.code, delivery.attempts,
|
|
83
|
+
delivery.last_error AS "lastError", delivery.last_attempt_at AS "lastAttemptAt",
|
|
84
|
+
delivery.next_attempt_at AS "nextAttemptAt", delivery.created_at AS "createdAt",
|
|
85
|
+
(SELECT earlier.id FROM events.app_deliveries earlier
|
|
86
|
+
WHERE earlier.subscription_id = delivery.subscription_id AND earlier.subject_key = delivery.subject_key
|
|
87
|
+
AND earlier.seq < delivery.seq AND earlier.state IN ('pending', 'retrying', 'failed')
|
|
88
|
+
AND delivery.state IN ('pending', 'retrying') ORDER BY earlier.seq LIMIT 1) AS "heldBy",
|
|
89
|
+
count(*) OVER()::int AS total
|
|
90
|
+
FROM events.app_deliveries delivery
|
|
91
|
+
JOIN events.app_subscriptions subscription ON subscription.id = delivery.subscription_id
|
|
92
|
+
JOIN events.app_events event ON event.id = delivery.event_id
|
|
93
|
+
WHERE (${filter.app ?? null}::text IS NULL OR subscription.app_id = ${filter.app ?? null})
|
|
94
|
+
AND (${filter.state ?? null}::text IS NULL OR delivery.state = ${filter.state ?? null})
|
|
95
|
+
AND (${filter.eventType ?? null}::text IS NULL OR event.type = ${filter.eventType ?? null})
|
|
96
|
+
ORDER BY delivery.seq DESC LIMIT ${limit} OFFSET ${offset}
|
|
97
|
+
`;
|
|
98
|
+
return {
|
|
99
|
+
items: rows.map((row: Record<string, unknown>) => {
|
|
100
|
+
const { total, ...item } = row;
|
|
101
|
+
return item;
|
|
102
|
+
}),
|
|
103
|
+
pagination: { offset, limit, total: Number(rows[0]?.total ?? 0) },
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const redeliver = async (id: string, actor: AuditActor) =>
|
|
107
|
+
db.begin(async (tx) => {
|
|
108
|
+
const changed = await tx`
|
|
109
|
+
UPDATE events.app_deliveries delivery SET state = 'pending', code = NULL, last_error = NULL,
|
|
110
|
+
attempts = 0, unreachable_count = 0, retained_from = now(), next_attempt_at = now(), claimed_until = NULL, claim_token = NULL, updated_at = now()
|
|
111
|
+
WHERE delivery.id = ${id}::uuid AND delivery.state IN ('failed', 'cancelled', 'done')
|
|
112
|
+
AND EXISTS(SELECT FROM events.app_subscriptions subscription
|
|
113
|
+
WHERE subscription.id = delivery.subscription_id AND subscription.state = 'active')
|
|
114
|
+
AND EXISTS(SELECT FROM events.app_events event WHERE event.id = delivery.event_id AND event.state = 'accepted')
|
|
115
|
+
RETURNING event_id AS "eventId", id
|
|
116
|
+
`;
|
|
117
|
+
if (!changed.length) return null;
|
|
118
|
+
await audit.record(
|
|
119
|
+
{
|
|
120
|
+
action: "app_events.delivery.redeliver",
|
|
121
|
+
outcome: "allowed",
|
|
122
|
+
actor,
|
|
123
|
+
target: { type: "app_delivery", id },
|
|
124
|
+
metadata: { eventId: changed[0]?.eventId },
|
|
125
|
+
},
|
|
126
|
+
tx,
|
|
127
|
+
);
|
|
128
|
+
return changed[0];
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const status = async () => {
|
|
132
|
+
const [stored] = await db`
|
|
133
|
+
SELECT state, code, last_sequence AS "lastSequence", gap_from AS "gapFrom", gap_to AS "gapTo", updated_at AS "updatedAt"
|
|
134
|
+
FROM events.app_broker_status WHERE id = true
|
|
135
|
+
`;
|
|
136
|
+
const [rejected] = await db`SELECT count(*)::int AS count FROM events.app_events WHERE state = 'rejected'`;
|
|
137
|
+
const rejections = await db`
|
|
138
|
+
SELECT type, source, code, received_at AS time FROM events.app_events
|
|
139
|
+
WHERE state = 'rejected' ORDER BY seq DESC LIMIT 10
|
|
140
|
+
`;
|
|
141
|
+
const deadLetters = await dependencies.deadLetters();
|
|
142
|
+
return {
|
|
143
|
+
...stored,
|
|
144
|
+
rejectedCount: Number(rejected?.count ?? 0),
|
|
145
|
+
rejections,
|
|
146
|
+
rejectionLimit: 10,
|
|
147
|
+
deadLetters: deadLetters.map(({ messageId, eventId, consumer, attempts, failedAt, reason }) => ({
|
|
148
|
+
messageId,
|
|
149
|
+
eventId,
|
|
150
|
+
consumer,
|
|
151
|
+
attempts,
|
|
152
|
+
failedAt,
|
|
153
|
+
reason,
|
|
154
|
+
})),
|
|
155
|
+
deadLetterLimit: 100,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
const checkGap = async () => {
|
|
159
|
+
const [stored] = await db<{ last_sequence: string }[]>`
|
|
160
|
+
SELECT last_sequence FROM events.app_broker_status WHERE id = true
|
|
161
|
+
`;
|
|
162
|
+
let position = Number(stored?.last_sequence ?? 0);
|
|
163
|
+
const seen = new Set<number>();
|
|
164
|
+
const deadline = performance.now() + 30_000;
|
|
165
|
+
let after: string | undefined;
|
|
166
|
+
while (true) {
|
|
167
|
+
if (performance.now() >= deadline) return;
|
|
168
|
+
const page = await dependencies.deadLetters(after);
|
|
169
|
+
for (const deadLetter of page) {
|
|
170
|
+
if (deadLetter.consumer === "core-app-events-v1" && deadLetter.event) seen.add(deadLetter.event.sequence);
|
|
171
|
+
}
|
|
172
|
+
if (page.length < 100) break;
|
|
173
|
+
const next = page.at(-1)?.messageId;
|
|
174
|
+
if (!next || next === after) return;
|
|
175
|
+
after = next;
|
|
176
|
+
}
|
|
177
|
+
if (seen.size) {
|
|
178
|
+
await db`
|
|
179
|
+
UPDATE events.app_broker_status SET unseen_sequences = unseen_sequences - (
|
|
180
|
+
SELECT range_agg(int8range(sequence, sequence + 1, '[)'))
|
|
181
|
+
FROM unnest(${toPgTextArray([...seen].map(String))}::bigint[]) AS sequence
|
|
182
|
+
) WHERE id = true AND NOT isempty(unseen_sequences)
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
while (seen.has(position + 1)) position++;
|
|
186
|
+
await db`
|
|
187
|
+
UPDATE events.app_broker_status SET last_sequence = ${position}, updated_at = now()
|
|
188
|
+
WHERE id = true AND last_sequence < ${position}
|
|
189
|
+
`;
|
|
190
|
+
for await (const stream of dependencies.streams()) {
|
|
191
|
+
if (stream.id !== "cloud:app-events") continue;
|
|
192
|
+
await db`
|
|
193
|
+
UPDATE events.app_broker_status SET state = 'gap_detected', code = 'reconcile_required',
|
|
194
|
+
gap_from = COALESCE(gap_from, LEAST(lower(unseen_sequences), last_sequence + 1)), gap_to = ${stream.firstSequence - 1}, updated_at = now()
|
|
195
|
+
WHERE id = true AND LEAST(lower(unseen_sequences), last_sequence + 1) < ${stream.firstSequence}
|
|
196
|
+
AND (state <> 'gap_detected' OR gap_to IS DISTINCT FROM ${stream.firstSequence - 1}::bigint)
|
|
197
|
+
`;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const acknowledgeGap = async (actor: AuditActor) =>
|
|
202
|
+
db.begin(async (tx) => {
|
|
203
|
+
const [previous] = await tx`
|
|
204
|
+
SELECT state, gap_from, gap_to FROM events.app_broker_status WHERE id = true FOR UPDATE
|
|
205
|
+
`;
|
|
206
|
+
if (previous?.state !== "gap_detected") return null;
|
|
207
|
+
const [result] = await tx`
|
|
208
|
+
UPDATE events.app_broker_status SET state = 'ready', code = NULL,
|
|
209
|
+
last_sequence = GREATEST(last_sequence, gap_to),
|
|
210
|
+
unseen_sequences = unseen_sequences - int8multirange(int8range(NULL, gap_to + 1, '[)')), gap_from = NULL, gap_to = NULL, updated_at = now()
|
|
211
|
+
WHERE id = true RETURNING state, last_sequence AS "lastSequence"
|
|
212
|
+
`;
|
|
213
|
+
await audit.record(
|
|
214
|
+
{
|
|
215
|
+
action: "app_events.gap.acknowledge",
|
|
216
|
+
outcome: "allowed",
|
|
217
|
+
actor,
|
|
218
|
+
target: { type: "app_event_broker", id: "core-app-events-v1" },
|
|
219
|
+
metadata: { gapFrom: previous.gap_from, gapTo: previous.gap_to },
|
|
220
|
+
},
|
|
221
|
+
tx,
|
|
222
|
+
);
|
|
223
|
+
return result;
|
|
224
|
+
});
|
|
225
|
+
const retain = async () =>
|
|
226
|
+
db.begin(async (tx) => {
|
|
227
|
+
await tx`
|
|
228
|
+
UPDATE events.app_deliveries SET state = 'cancelled', code = 'retention_expired',
|
|
229
|
+
claimed_until = NULL, claim_token = NULL, updated_at = now()
|
|
230
|
+
WHERE id IN (
|
|
231
|
+
SELECT id FROM events.app_deliveries
|
|
232
|
+
WHERE state IN ('pending', 'retrying', 'failed') AND retained_from < now() - interval '30 days' LIMIT 1000
|
|
233
|
+
)
|
|
234
|
+
`;
|
|
235
|
+
await tx`
|
|
236
|
+
DELETE FROM events.app_deliveries WHERE id IN (
|
|
237
|
+
SELECT id FROM events.app_deliveries
|
|
238
|
+
WHERE state IN ('done', 'cancelled') AND updated_at < now() - interval '7 days' LIMIT 1000
|
|
239
|
+
)
|
|
240
|
+
`;
|
|
241
|
+
await tx`
|
|
242
|
+
DELETE FROM events.app_events WHERE id IN (
|
|
243
|
+
SELECT event.id FROM events.app_events event
|
|
244
|
+
WHERE received_at < now() - interval '30 days'
|
|
245
|
+
AND NOT EXISTS(SELECT FROM events.app_deliveries delivery WHERE delivery.event_id = event.id) LIMIT 1000
|
|
246
|
+
)
|
|
247
|
+
`;
|
|
248
|
+
});
|
|
249
|
+
return { catalog, subscriptions, deliveries, redeliver, status, checkGap, acknowledgeGap, retain };
|
|
250
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { capabilityHash } from "../../_internal/capability-schemas";
|
|
3
|
+
import { appEventSubscriptionId } from "../../contracts/app-events";
|
|
4
|
+
import type { CapabilityRegistryEntry } from "../../contracts/registry";
|
|
5
|
+
import { audit } from "../audit";
|
|
6
|
+
import { toPgTextArray } from "../postgres";
|
|
7
|
+
|
|
8
|
+
export type StoredDeclaration = {
|
|
9
|
+
app_id: string;
|
|
10
|
+
type: string;
|
|
11
|
+
version: number;
|
|
12
|
+
subject: string;
|
|
13
|
+
place: string | null;
|
|
14
|
+
automation: boolean;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
data_schema: Record<string, unknown>;
|
|
18
|
+
schema_hash: string;
|
|
19
|
+
retired_at: Date | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Transition = { id: string; previous_state: string; state: string; reason: string | null };
|
|
23
|
+
async function auditTransitions(tx: SQL, transitions: Transition[]) {
|
|
24
|
+
for (const transition of transitions) {
|
|
25
|
+
await audit.record(
|
|
26
|
+
{
|
|
27
|
+
action: "app_events.subscription.state_changed",
|
|
28
|
+
outcome: "allowed",
|
|
29
|
+
target: { type: "app_subscription", id: transition.id },
|
|
30
|
+
metadata: { previousState: transition.previous_state, state: transition.state, reason: transition.reason },
|
|
31
|
+
},
|
|
32
|
+
tx,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createDeclarationSync(db: SQL, registry: () => Promise<CapabilityRegistryEntry[]>) {
|
|
38
|
+
let previousSnapshot: string | undefined;
|
|
39
|
+
let lastCheckedAt = -Infinity;
|
|
40
|
+
let running: Promise<void> | undefined;
|
|
41
|
+
|
|
42
|
+
async function sync(entries: CapabilityRegistryEntry[]) {
|
|
43
|
+
await db.begin(async (tx) => {
|
|
44
|
+
for (const entry of entries.toSorted((left, right) => left.appId.localeCompare(right.appId))) {
|
|
45
|
+
const hash = capabilityHash({ events: entry.manifest.events ?? [], subscribes: entry.manifest.subscribes ?? [] });
|
|
46
|
+
const [known] = await tx`SELECT hash FROM events.app_manifests WHERE app_id = ${entry.appId}`;
|
|
47
|
+
if (known?.hash === hash) continue;
|
|
48
|
+
// Serialize only replicas observing a changed manifest for this application.
|
|
49
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext(${`cloud.app-events:${entry.appId}`}))`;
|
|
50
|
+
const [stored] = await tx`SELECT hash FROM events.app_manifests WHERE app_id = ${entry.appId}`;
|
|
51
|
+
if (stored?.hash === hash) continue;
|
|
52
|
+
const declared = (entry.manifest.events ?? []).map((event) => `${entry.appId}.${event.localId}@${event.version}`);
|
|
53
|
+
await tx`
|
|
54
|
+
UPDATE events.app_declarations SET retired_at = now()
|
|
55
|
+
WHERE app_id = ${entry.appId} AND retired_at IS NULL
|
|
56
|
+
AND NOT(type || '@' || version::text = ANY(${toPgTextArray(declared)}::text[]))
|
|
57
|
+
`;
|
|
58
|
+
for (const event of entry.manifest.events ?? []) {
|
|
59
|
+
const type = `${entry.appId}.${event.localId}`;
|
|
60
|
+
const schemaHash = event.schemaHash;
|
|
61
|
+
await tx`
|
|
62
|
+
INSERT INTO events.app_declarations (
|
|
63
|
+
app_id, type, version, subject, place, automation, title, description, data_schema, schema_hash
|
|
64
|
+
) VALUES (
|
|
65
|
+
${entry.appId}, ${type}, ${event.version}, ${event.subject}, ${event.place ?? null},
|
|
66
|
+
${event.automation !== false}, ${event.title}, ${event.description},
|
|
67
|
+
${JSON.stringify(event.dataSchema)}::text::jsonb, ${schemaHash}
|
|
68
|
+
) ON CONFLICT(app_id, type, version) DO UPDATE SET
|
|
69
|
+
subject = EXCLUDED.subject, place = EXCLUDED.place, automation = EXCLUDED.automation,
|
|
70
|
+
title = EXCLUDED.title, description = EXCLUDED.description, data_schema = EXCLUDED.data_schema,
|
|
71
|
+
schema_hash = EXCLUDED.schema_hash, retired_at = NULL
|
|
72
|
+
WHERE (app_declarations.subject, app_declarations.place, app_declarations.automation,
|
|
73
|
+
app_declarations.title, app_declarations.description, app_declarations.schema_hash)
|
|
74
|
+
IS DISTINCT FROM (EXCLUDED.subject, EXCLUDED.place, EXCLUDED.automation,
|
|
75
|
+
EXCLUDED.title, EXCLUDED.description, EXCLUDED.schema_hash)
|
|
76
|
+
OR app_declarations.retired_at IS NOT NULL
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
const ids: string[] = [];
|
|
80
|
+
for (const subscription of entry.manifest.subscribes ?? []) {
|
|
81
|
+
const id = appEventSubscriptionId(entry.appId, subscription.type, subscription.version);
|
|
82
|
+
ids.push(id);
|
|
83
|
+
const inserted = await tx`
|
|
84
|
+
INSERT INTO events.app_subscriptions(id, app_id, event_type, version, state, reason)
|
|
85
|
+
VALUES (${id}, ${entry.appId}, ${subscription.type}, ${subscription.version}, 'blocked', 'event_not_declared')
|
|
86
|
+
ON CONFLICT DO NOTHING RETURNING id
|
|
87
|
+
`;
|
|
88
|
+
if (inserted.length) {
|
|
89
|
+
await audit.record(
|
|
90
|
+
{
|
|
91
|
+
action: "app_events.subscription.first_seen",
|
|
92
|
+
outcome: "allowed",
|
|
93
|
+
target: { type: "app_subscription", id },
|
|
94
|
+
metadata: { appId: entry.appId, eventType: subscription.type, version: subscription.version },
|
|
95
|
+
},
|
|
96
|
+
tx,
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
const restored = await tx<Transition[]>`
|
|
100
|
+
WITH previous AS MATERIALIZED (
|
|
101
|
+
SELECT id, state AS previous_state FROM events.app_subscriptions WHERE id = ${id} AND state = 'cancelled'
|
|
102
|
+
FOR UPDATE
|
|
103
|
+
) UPDATE events.app_subscriptions subscription SET
|
|
104
|
+
state = 'blocked', reason = 'event_not_declared', updated_at = now()
|
|
105
|
+
FROM previous WHERE subscription.id = previous.id
|
|
106
|
+
RETURNING subscription.id, previous.previous_state, subscription.state, subscription.reason
|
|
107
|
+
`;
|
|
108
|
+
await auditTransitions(tx, restored);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const removed = await tx<Transition[]>`
|
|
112
|
+
WITH previous AS MATERIALIZED (
|
|
113
|
+
SELECT id, state AS previous_state FROM events.app_subscriptions
|
|
114
|
+
WHERE app_id = ${entry.appId} AND NOT(id = ANY(${toPgTextArray(ids)}::text[])) AND state <> 'cancelled'
|
|
115
|
+
ORDER BY id FOR UPDATE
|
|
116
|
+
) UPDATE events.app_subscriptions subscription SET
|
|
117
|
+
state = 'cancelled', reason = 'subscription_removed', updated_at = now()
|
|
118
|
+
FROM previous WHERE subscription.id = previous.id
|
|
119
|
+
RETURNING subscription.id, previous.previous_state, subscription.state, subscription.reason
|
|
120
|
+
`;
|
|
121
|
+
await auditTransitions(tx, removed);
|
|
122
|
+
if (removed.length) {
|
|
123
|
+
await tx`
|
|
124
|
+
UPDATE events.app_deliveries SET state = 'cancelled', code = 'subscription_removed',
|
|
125
|
+
claimed_until = NULL, claim_token = NULL, updated_at = now()
|
|
126
|
+
WHERE subscription_id = ANY(${toPgTextArray(removed.map(({ id }) => id))}::text[])
|
|
127
|
+
AND state IN ('pending', 'retrying', 'failed')
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
await tx`
|
|
131
|
+
INSERT INTO events.app_manifests(app_id, hash) VALUES (${entry.appId}, ${hash})
|
|
132
|
+
ON CONFLICT(app_id) DO UPDATE SET hash = EXCLUDED.hash, updated_at = now()
|
|
133
|
+
WHERE app_manifests.hash IS DISTINCT FROM EXCLUDED.hash
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
// A newly observed publisher can unblock an offline subscriber too.
|
|
137
|
+
const transitions = await tx<Transition[]>`
|
|
138
|
+
WITH desired AS MATERIALIZED (
|
|
139
|
+
SELECT subscription.id, subscription.state AS previous_state,
|
|
140
|
+
CASE WHEN declaration.type IS NULL THEN 'blocked'
|
|
141
|
+
WHEN declaration.place IS NOT NULL THEN 'blocked' ELSE 'active' END AS state,
|
|
142
|
+
CASE WHEN declaration.type IS NULL THEN 'event_not_declared'
|
|
143
|
+
WHEN declaration.place IS NOT NULL THEN 'resource_events_unsupported' ELSE NULL END AS reason
|
|
144
|
+
FROM events.app_subscriptions subscription
|
|
145
|
+
LEFT JOIN events.app_declarations declaration ON declaration.type = subscription.event_type
|
|
146
|
+
AND declaration.version = subscription.version
|
|
147
|
+
AND declaration.app_id = split_part(subscription.event_type, '.', 1) AND declaration.retired_at IS NULL
|
|
148
|
+
WHERE subscription.state <> 'cancelled'
|
|
149
|
+
AND (subscription.state, subscription.reason) IS DISTINCT FROM (
|
|
150
|
+
CASE WHEN declaration.type IS NULL OR declaration.place IS NOT NULL THEN 'blocked' ELSE 'active' END,
|
|
151
|
+
CASE WHEN declaration.type IS NULL THEN 'event_not_declared'
|
|
152
|
+
WHEN declaration.place IS NOT NULL THEN 'resource_events_unsupported' ELSE NULL END
|
|
153
|
+
)
|
|
154
|
+
ORDER BY subscription.id FOR UPDATE OF subscription
|
|
155
|
+
) UPDATE events.app_subscriptions subscription SET state = desired.state, reason = desired.reason, updated_at = now()
|
|
156
|
+
FROM desired WHERE subscription.id = desired.id
|
|
157
|
+
AND (subscription.state, subscription.reason) IS DISTINCT FROM (desired.state, desired.reason)
|
|
158
|
+
RETURNING subscription.id, desired.previous_state, subscription.state, subscription.reason
|
|
159
|
+
`;
|
|
160
|
+
await auditTransitions(tx, transitions);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function reconcileSubscriptions(snapshot?: CapabilityRegistryEntry[]) {
|
|
165
|
+
if (running) await running;
|
|
166
|
+
const entries = snapshot ?? (await registry());
|
|
167
|
+
const snapshotHash = capabilityHash(
|
|
168
|
+
entries
|
|
169
|
+
.map(({ appId, manifest }) => ({
|
|
170
|
+
appId,
|
|
171
|
+
events: manifest.events ?? [],
|
|
172
|
+
subscribes: manifest.subscribes ?? [],
|
|
173
|
+
}))
|
|
174
|
+
.sort((left, right) => left.appId.localeCompare(right.appId)),
|
|
175
|
+
);
|
|
176
|
+
if (snapshotHash === previousSnapshot && performance.now() - lastCheckedAt < 30_000) return;
|
|
177
|
+
running = sync(entries);
|
|
178
|
+
try {
|
|
179
|
+
await running;
|
|
180
|
+
previousSnapshot = snapshotHash;
|
|
181
|
+
lastCheckedAt = performance.now();
|
|
182
|
+
} finally {
|
|
183
|
+
running = undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { reconcileSubscriptions };
|
|
187
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { getCapability } from "../../_internal/registry";
|
|
3
|
+
import type { AppEventEnvelope } from "../../contracts/app-events";
|
|
4
|
+
import { audit } from "../audit";
|
|
5
|
+
import { signAppEventInvocation } from "../identity/invocation-token";
|
|
6
|
+
import { toPgTextArray } from "../postgres";
|
|
7
|
+
|
|
8
|
+
export const APP_EVENT_DELIVERY_BACKOFF_MS = [1_000, 5_000, 30_000, 120_000, 600_000, 1_800_000, 3_600_000] as const;
|
|
9
|
+
export const APP_EVENT_DELIVERY_ATTEMPTS = APP_EVENT_DELIVERY_BACKOFF_MS.length + 1;
|
|
10
|
+
const BATCH_SIZE = 32;
|
|
11
|
+
const CONCURRENCY = 4;
|
|
12
|
+
// The subscriber has 30 seconds to answer before Core ends the request.
|
|
13
|
+
const DELIVERY_TIMEOUT_MS = 45_000;
|
|
14
|
+
type Delivery = {
|
|
15
|
+
id: string;
|
|
16
|
+
event_id: string;
|
|
17
|
+
subscription_id: string;
|
|
18
|
+
attempts: number;
|
|
19
|
+
unreachable_count: number;
|
|
20
|
+
claim_token: string;
|
|
21
|
+
envelope: AppEventEnvelope;
|
|
22
|
+
app_id: string;
|
|
23
|
+
};
|
|
24
|
+
type Dependencies = {
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
sign?: typeof signAppEventInvocation;
|
|
27
|
+
};
|
|
28
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 1000);
|
|
29
|
+
async function auditFailure(tx: SQL, row: Delivery, code: string | null) {
|
|
30
|
+
await audit.record(
|
|
31
|
+
{
|
|
32
|
+
action: "app_events.delivery.failed",
|
|
33
|
+
outcome: "failed",
|
|
34
|
+
target: { type: "app_delivery", id: row.id },
|
|
35
|
+
error: { code: code ?? "handler_failed" },
|
|
36
|
+
metadata: { eventId: row.event_id, subscriptionId: row.subscription_id, attempts: row.attempts },
|
|
37
|
+
},
|
|
38
|
+
tx,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createEventDelivery(
|
|
43
|
+
db: SQL,
|
|
44
|
+
findApp: typeof getCapability,
|
|
45
|
+
reconcileSubscriptions: () => Promise<void>,
|
|
46
|
+
dependencies: Dependencies,
|
|
47
|
+
) {
|
|
48
|
+
async function postpone(row: Delivery) {
|
|
49
|
+
const delay = Math.min(30_000 * 2 ** Math.min(row.unreachable_count, 7), 3_600_000);
|
|
50
|
+
await db`
|
|
51
|
+
UPDATE events.app_deliveries SET state = 'retrying', code = 'subscriber_unreachable', attempts = attempts - 1,
|
|
52
|
+
unreachable_count = unreachable_count + 1, claimed_until = NULL, claim_token = NULL,
|
|
53
|
+
next_attempt_at = now() + ${delay} * interval '1 millisecond', updated_at = now()
|
|
54
|
+
WHERE id = ${row.id}::uuid AND claim_token = ${row.claim_token}::uuid AND state IN ('pending', 'retrying')
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
const claim = async () =>
|
|
58
|
+
db.begin(async (tx) => {
|
|
59
|
+
const exhausted = await tx<Delivery[]>`
|
|
60
|
+
UPDATE events.app_deliveries SET state = 'failed', code = 'attempts_exhausted',
|
|
61
|
+
claimed_until = NULL, claim_token = NULL, updated_at = now()
|
|
62
|
+
WHERE id IN (
|
|
63
|
+
SELECT id FROM events.app_deliveries WHERE state IN ('pending', 'retrying')
|
|
64
|
+
AND attempts >= ${APP_EVENT_DELIVERY_ATTEMPTS} AND (claimed_until IS NULL OR claimed_until <= now())
|
|
65
|
+
ORDER BY seq LIMIT ${BATCH_SIZE} FOR UPDATE SKIP LOCKED
|
|
66
|
+
) RETURNING *
|
|
67
|
+
`;
|
|
68
|
+
for (const delivery of exhausted) await auditFailure(tx, delivery, "attempts_exhausted");
|
|
69
|
+
const rows = await tx<Delivery[]>`
|
|
70
|
+
WITH candidates AS MATERIALIZED (
|
|
71
|
+
SELECT delivery.id FROM events.app_deliveries delivery
|
|
72
|
+
JOIN events.app_subscriptions subscription ON subscription.id = delivery.subscription_id
|
|
73
|
+
WHERE delivery.state IN ('pending', 'retrying') AND delivery.next_attempt_at <= now()
|
|
74
|
+
AND (delivery.claimed_until IS NULL OR delivery.claimed_until <= now())
|
|
75
|
+
AND delivery.attempts < ${APP_EVENT_DELIVERY_ATTEMPTS} AND subscription.state = 'active'
|
|
76
|
+
AND NOT EXISTS (
|
|
77
|
+
SELECT FROM events.app_deliveries earlier
|
|
78
|
+
WHERE earlier.subscription_id = delivery.subscription_id AND earlier.subject_key = delivery.subject_key
|
|
79
|
+
AND earlier.seq < delivery.seq AND earlier.state IN ('pending', 'retrying', 'failed')
|
|
80
|
+
)
|
|
81
|
+
ORDER BY delivery.seq LIMIT ${CONCURRENCY} FOR UPDATE OF delivery SKIP LOCKED
|
|
82
|
+
) UPDATE events.app_deliveries delivery SET claimed_until = now() + interval '60 seconds',
|
|
83
|
+
claim_token = gen_random_uuid(), attempts = delivery.attempts + 1, last_attempt_at = now(), updated_at = now()
|
|
84
|
+
FROM candidates WHERE delivery.id = candidates.id RETURNING delivery.*
|
|
85
|
+
`;
|
|
86
|
+
if (!rows.length) return [];
|
|
87
|
+
return tx<Delivery[]>`
|
|
88
|
+
SELECT delivery.*, subscription.app_id, event.envelope
|
|
89
|
+
FROM events.app_deliveries delivery
|
|
90
|
+
JOIN events.app_subscriptions subscription ON subscription.id = delivery.subscription_id
|
|
91
|
+
JOIN events.app_events event ON event.id = delivery.event_id
|
|
92
|
+
WHERE delivery.id = ANY(${toPgTextArray(rows.map((row) => row.id))}::uuid[]) ORDER BY delivery.seq
|
|
93
|
+
`;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const finish = async (
|
|
97
|
+
row: Delivery,
|
|
98
|
+
state: "done" | "cancelled" | "retrying" | "failed",
|
|
99
|
+
code: string | null,
|
|
100
|
+
error: string | null = null,
|
|
101
|
+
) =>
|
|
102
|
+
db.begin(async (tx) => {
|
|
103
|
+
const delay = APP_EVENT_DELIVERY_BACKOFF_MS[Math.min(row.attempts - 1, APP_EVENT_DELIVERY_BACKOFF_MS.length - 1)] ?? 0;
|
|
104
|
+
const changed = await tx`
|
|
105
|
+
UPDATE events.app_deliveries SET state = ${state}, code = ${code}, last_error = ${error},
|
|
106
|
+
claimed_until = NULL, claim_token = NULL, unreachable_count = 0,
|
|
107
|
+
next_attempt_at = now() + ${delay} * interval '1 millisecond', updated_at = now()
|
|
108
|
+
WHERE id = ${row.id}::uuid AND claim_token = ${row.claim_token}::uuid AND state IN ('pending', 'retrying')
|
|
109
|
+
RETURNING id
|
|
110
|
+
`;
|
|
111
|
+
if (state === "done" && changed.length) {
|
|
112
|
+
await tx`
|
|
113
|
+
UPDATE events.app_deliveries delivery SET next_attempt_at = now(), unreachable_count = 0, updated_at = now()
|
|
114
|
+
FROM events.app_subscriptions subscription
|
|
115
|
+
WHERE subscription.id = delivery.subscription_id AND subscription.app_id = ${row.app_id}
|
|
116
|
+
AND delivery.state = 'retrying' AND delivery.code = 'subscriber_unreachable'
|
|
117
|
+
AND delivery.claim_token IS NULL AND delivery.next_attempt_at > now()
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
if (state === "failed" && changed.length) await auditFailure(tx, row, code);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const deliver = async (row: Delivery) => {
|
|
124
|
+
try {
|
|
125
|
+
const [subscription] = await db`
|
|
126
|
+
SELECT state, reason FROM events.app_subscriptions WHERE id = ${row.subscription_id}
|
|
127
|
+
`;
|
|
128
|
+
if (subscription?.state === "cancelled") {
|
|
129
|
+
await finish(row, "cancelled", "subscription_removed");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (subscription?.state !== "active") {
|
|
133
|
+
// A declaration transition can block a subscription after this delivery was claimed.
|
|
134
|
+
await db`
|
|
135
|
+
UPDATE events.app_deliveries SET state = 'retrying', code = ${subscription?.reason ?? "subscription_blocked"},
|
|
136
|
+
attempts = attempts - 1, claimed_until = NULL, claim_token = NULL, next_attempt_at = now(), updated_at = now()
|
|
137
|
+
WHERE id = ${row.id}::uuid AND claim_token = ${row.claim_token}::uuid AND state IN ('pending', 'retrying')
|
|
138
|
+
`;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const app = await findApp(row.app_id);
|
|
142
|
+
if (!app) {
|
|
143
|
+
await postpone(row);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (row.envelope.place) {
|
|
147
|
+
await finish(row, "failed", "resource_events_unsupported");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const payload = { envelope: row.envelope, subscriptionId: row.subscription_id, attempt: row.attempts };
|
|
151
|
+
const token = await (dependencies.sign ?? signAppEventInvocation)({
|
|
152
|
+
appId: row.app_id,
|
|
153
|
+
subscriptionId: row.subscription_id,
|
|
154
|
+
eventId: row.event_id,
|
|
155
|
+
payload,
|
|
156
|
+
});
|
|
157
|
+
const url = new URL("/api/_internal/events/v1/deliver", app.endpoint);
|
|
158
|
+
let response: Response;
|
|
159
|
+
try {
|
|
160
|
+
response = await (dependencies.fetch ?? fetch)(url, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
163
|
+
body: JSON.stringify(payload),
|
|
164
|
+
redirect: "error",
|
|
165
|
+
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
169
|
+
// Only errors that prove no request reached the subscriber refund an attempt.
|
|
170
|
+
if (["ConnectionRefused", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH"].includes(String(code))) {
|
|
171
|
+
await postpone(row);
|
|
172
|
+
} else {
|
|
173
|
+
await finish(
|
|
174
|
+
row,
|
|
175
|
+
row.attempts >= APP_EVENT_DELIVERY_ATTEMPTS ? "failed" : "retrying",
|
|
176
|
+
error instanceof Error && error.name === "TimeoutError" ? "handler_timeout" : "connection_lost",
|
|
177
|
+
message(error),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (response.ok) {
|
|
183
|
+
await finish(row, "done", null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (response.status === 422) {
|
|
187
|
+
await finish(row, "failed", "schema_mismatch");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (response.status === 410) {
|
|
191
|
+
await finish(row, row.attempts >= APP_EVENT_DELIVERY_ATTEMPTS ? "failed" : "retrying", "subscription_unavailable");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
throw new Error(`Subscriber returned HTTP ${response.status}`);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await finish(
|
|
197
|
+
row,
|
|
198
|
+
row.attempts >= APP_EVENT_DELIVERY_ATTEMPTS ? "failed" : "retrying",
|
|
199
|
+
row.attempts >= APP_EVENT_DELIVERY_ATTEMPTS ? "attempts_exhausted" : "handler_failed",
|
|
200
|
+
message(error),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const work = async () => {
|
|
206
|
+
await reconcileSubscriptions();
|
|
207
|
+
let total = 0;
|
|
208
|
+
for (let batch = 0; batch < BATCH_SIZE / CONCURRENCY; batch++) {
|
|
209
|
+
const rows = await claim();
|
|
210
|
+
await Promise.all(rows.map(deliver));
|
|
211
|
+
total += rows.length;
|
|
212
|
+
if (!rows.length) break;
|
|
213
|
+
}
|
|
214
|
+
return total;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return { claim, deliver, work };
|
|
218
|
+
}
|