@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.
Files changed (308) hide show
  1. package/package.json +11 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. 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
+ }