@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -2,18 +2,19 @@ import { sql } from "bun";
2
2
  import {
3
3
  DEFAULT_LINUX_IDENTITY_CONFIGURATION,
4
4
  isPosixName,
5
- LinuxIdentityConfigurationSchema,
6
5
  type LinuxIdentityConfiguration,
6
+ LinuxIdentityConfigurationSchema,
7
7
  type PosixIdentity,
8
8
  PosixOverridesSchema,
9
9
  } from "../../contracts/posix";
10
+ import type { MutationResult } from "../../contracts/shared";
10
11
  import { freeipa } from "../../server/services";
12
+ import { type AuditActor, audit } from "../audit";
11
13
  import { getFreeIpaConfig } from "../freeipa-config";
12
14
  import { readCompleteIpaList } from "../ipa/sync-planning";
13
- import { decryptValue } from "../settings/crypto";
14
15
  import * as settings from "../settings";
15
- import { audit, type AuditActor } from "../audit";
16
- import type { MutationResult } from "../../contracts/shared";
16
+ import { decryptValue } from "../settings/crypto";
17
+ import { create as createLocalGroup } from "./local-groups";
17
18
 
18
19
  const CONFIG_KEY = "linux.identity_config";
19
20
  export const POSIX_PAGE_SIZE = 50;
@@ -274,6 +275,34 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
274
275
  }
275
276
  };
276
277
 
278
+ const assignGroup = async (tx: typeof sql, actor: Actor, id: string, ranges: IpaIdRange[]) => {
279
+ const config = await readConfig(tx);
280
+ if (!config.enabled) throw new PosixError("setup_disabled");
281
+ const [group] = await tx<
282
+ { id: string; name: string; provider: string; gid_number: number | null }[]
283
+ >`SELECT id, name, provider, gid_number FROM auth.groups WHERE id = ${id}::uuid`;
284
+ if (!group) throw new PosixError("group_not_found", 404);
285
+ if (group.provider !== "local") throw new PosixError("identity_not_locally_managed");
286
+ if (group.gid_number !== null) return { gidNumber: group.gid_number };
287
+ if (!isPosixName(group.name)) throw new PosixError("invalid_name");
288
+ const [conflict] = await tx`SELECT 1 FROM auth.groups WHERE id <> ${id}::uuid AND lower(name) = lower(${group.name}) LIMIT 1`;
289
+ if (conflict) throw new PosixError("group_conflict");
290
+ await checkRange(tx, config, ranges);
291
+ const gidNumber = await allocate(tx, "gid", id, config);
292
+ await tx`UPDATE auth.groups SET gid_number = ${gidNumber} WHERE id = ${id}::uuid`;
293
+ await audit.record(
294
+ {
295
+ action: "accounts.linux.provision_group",
296
+ outcome: "allowed",
297
+ actor: { userId: actor.id, roles: actor.roles },
298
+ target: { type: "group", id },
299
+ metadata: { gidNumber },
300
+ },
301
+ tx,
302
+ );
303
+ return { gidNumber };
304
+ };
305
+
277
306
  const service = {
278
307
  async configuration(actor: Actor) {
279
308
  requireAdmin(actor);
@@ -347,36 +376,23 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
347
376
  return { ...candidate(row), identity: { ...candidate(row).identity!, ...parsed.data } };
348
377
  });
349
378
  },
379
+ async createGroup(actor: Actor, input: { name: string; description?: string }) {
380
+ requireAdmin(actor);
381
+ const ranges = await readRanges();
382
+ return db.begin(async (tx) => {
383
+ await lock(tx);
384
+ const created = await createLocalGroup(input, tx);
385
+ if (!created.ok) throw new PosixError("group_conflict");
386
+ const { gidNumber } = await assignGroup(tx, actor, created.data.id, ranges);
387
+ return { ...created.data, gidnumber: gidNumber };
388
+ });
389
+ },
350
390
  async provisionGroup(actor: Actor, id: string) {
351
391
  requireAdmin(actor);
352
392
  const ranges = await readRanges();
353
393
  return db.begin(async (tx) => {
354
394
  await lock(tx);
355
- const config = await readConfig(tx);
356
- if (!config.enabled) throw new PosixError("setup_disabled");
357
- const [group] = await tx<
358
- { id: string; name: string; provider: string; gid_number: number | null }[]
359
- >`SELECT id, name, provider, gid_number FROM auth.groups WHERE id = ${id}::uuid`;
360
- if (!group) throw new PosixError("group_not_found", 404);
361
- if (group.provider !== "local") throw new PosixError("identity_not_locally_managed");
362
- if (group.gid_number !== null) return { gidNumber: group.gid_number };
363
- if (!isPosixName(group.name)) throw new PosixError("invalid_name");
364
- const [conflict] = await tx`SELECT 1 FROM auth.groups WHERE id <> ${id}::uuid AND lower(name) = lower(${group.name}) LIMIT 1`;
365
- if (conflict) throw new PosixError("group_conflict");
366
- await checkRange(tx, config, ranges);
367
- const gidNumber = await allocate(tx, "gid", id, config);
368
- await tx`UPDATE auth.groups SET gid_number = ${gidNumber} WHERE id = ${id}::uuid`;
369
- await audit.record(
370
- {
371
- action: "accounts.linux.provision_group",
372
- outcome: "allowed",
373
- actor: { userId: actor.id, roles: actor.roles },
374
- target: { type: "group", id },
375
- metadata: { gidNumber },
376
- },
377
- tx,
378
- );
379
- return { gidNumber };
395
+ return assignGroup(tx, actor, id, ranges);
380
396
  });
381
397
  },
382
398
  };
@@ -172,7 +172,7 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
172
172
  const { groupsAdmin } = await getFreeIpaConfig();
173
173
  const rows = await sql<DbRow[]>`
174
174
  SELECT u.*,
175
- ${userIpaDataColumns},
175
+ ${userIpaDataColumns()},
176
176
  CASE
177
177
  WHEN u.provider = 'local' THEN u.admin
178
178
  ELSE EXISTS(
@@ -203,7 +203,7 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
203
203
  ${managedGroupIdsSubquery(userIdExpr)}
204
204
  ), '{}') AS manages_group_ids
205
205
  FROM auth.users u
206
- ${userIpaDataJoin}
206
+ ${userIpaDataJoin()}
207
207
  WHERE ${whereClause}
208
208
  `;
209
209
  if (rows.length === 0) return null;
@@ -1,5 +1,11 @@
1
+ import { requestCacheRedis } from "../request-cache-redis";
1
2
  import { err, fail, ok, type Result } from "@k2b/stdlib";
2
3
  import { sql } from "bun";
4
+ import { HTTPException } from "hono/http-exception";
5
+ import { hasRole, type User } from "../../contracts/shared";
6
+ import { z } from "zod";
7
+ import { AnnouncementDisplayEntrySchema } from "../../contracts/announcements";
8
+ import { claimCacheFill, completeCacheFill } from "../cache-fill";
3
9
  import type {
4
10
  AnnouncementCookieState,
5
11
  AnnouncementDisplayEntry,
@@ -11,6 +17,17 @@ import { markdown } from "../../shared/markdown";
11
17
  import { logger } from "../logging";
12
18
 
13
19
  const log = logger("announcements");
20
+ const ACTIVE_CACHE_KEY = "appglobalcache:shared:announcements:v1";
21
+ const invalidateActiveCache = async () => (await requestCacheRedis()).del(ACTIVE_CACHE_KEY);
22
+ const invalidateAfterMutation = async () => {
23
+ try {
24
+ await invalidateActiveCache();
25
+ } catch (error) {
26
+ // The database mutation already succeeded; do not invite duplicate creates.
27
+ // Unavailable Redis reads fall back to Postgres; old cache entries expire.
28
+ log.warn("Announcement saved but cache invalidation failed", { error: error instanceof Error ? error.message : String(error) });
29
+ }
30
+ };
14
31
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
15
32
 
16
33
  type AnnouncementRow = {
@@ -119,6 +136,7 @@ const create = async (params: { data: CreateAnnouncement; actorId: string }): Pr
119
136
  RETURNING id, version, kind, title, body, tone, published_at, expires_at,
120
137
  created_at, updated_at, created_by, updated_by
121
138
  `;
139
+ if (row) await invalidateAfterMutation();
122
140
  return row ? ok(mapRow(row)) : fail(err.internal("Failed to create announcement."));
123
141
  } catch (error) {
124
142
  log.error("Failed to create announcement", { error: error instanceof Error ? error.message : String(error) });
@@ -156,6 +174,7 @@ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: s
156
174
  RETURNING id, version, kind, title, body, tone, published_at, expires_at,
157
175
  created_at, updated_at, created_by, updated_by
158
176
  `;
177
+ if (row) await invalidateAfterMutation();
159
178
  return row ? ok(mapRow(row)) : fail(err.notFound("Announcement"));
160
179
  } catch (error) {
161
180
  log.error("Failed to update announcement", { id: params.id, error: error instanceof Error ? error.message : String(error) });
@@ -166,6 +185,7 @@ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: s
166
185
  const remove = async (params: { id: string }): Promise<Result<void>> => {
167
186
  if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
168
187
  const result = await sql`DELETE FROM announcements.entries WHERE id = ${params.id}::uuid`;
188
+ if (result.count > 0) await invalidateAfterMutation();
169
189
  return result.count > 0 ? ok() : fail(err.notFound("Announcement"));
170
190
  };
171
191
 
@@ -182,28 +202,65 @@ const listActive = async (params: { now?: Date } = {}): Promise<AnnouncementEntr
182
202
  return rows.map(mapRow);
183
203
  };
184
204
 
185
- export const selectVisibleForState = (
186
- entries: AnnouncementEntry[],
205
+ const selectEntriesForState = <T extends { kind: AnnouncementEntry["kind"]; version: number }>(
206
+ entries: T[],
187
207
  state: AnnouncementCookieState,
188
- ): { banners: AnnouncementDisplayEntry[]; announcements: AnnouncementDisplayEntry[]; latestAnnouncementVersion: number } => {
208
+ ) => {
189
209
  const dismissedBanners = new Set(state.dismissedBannerVersions);
190
210
  const activeAnnouncements = entries.filter((entry) => entry.kind === "announcement");
191
211
  const latestAnnouncementVersion = activeAnnouncements.reduce((max, entry) => Math.max(max, entry.version), state.seenAnnouncementVersion);
192
212
  return {
193
213
  banners: entries
194
214
  .filter((entry) => entry.kind === "banner" && !dismissedBanners.has(entry.version))
195
- .sort((a, b) => b.version - a.version)
196
- .map(renderAnnouncement),
215
+ .sort((a, b) => b.version - a.version),
197
216
  announcements: activeAnnouncements
198
217
  .filter((entry) => entry.version > state.seenAnnouncementVersion)
199
- .sort((a, b) => b.version - a.version)
200
- .map(renderAnnouncement),
218
+ .sort((a, b) => b.version - a.version),
201
219
  latestAnnouncementVersion,
202
220
  };
203
221
  };
204
222
 
223
+ export const selectVisibleForState = (entries: AnnouncementEntry[], state: AnnouncementCookieState) => {
224
+ const selected = selectEntriesForState(entries, state);
225
+ return { ...selected, banners: selected.banners.map(renderAnnouncement), announcements: selected.announcements.map(renderAnnouncement) };
226
+ };
227
+
228
+ const cachedActive = async (): Promise<AnnouncementDisplayEntry[]> => {
229
+ let cached: string | null = null;
230
+ try {
231
+ cached = await (await requestCacheRedis()).get(ACTIVE_CACHE_KEY);
232
+ } catch {
233
+ /* Read authoritative data below. */
234
+ }
235
+ let entries: AnnouncementDisplayEntry[] | undefined;
236
+ if (cached !== null) {
237
+ try {
238
+ entries = z.array(AnnouncementDisplayEntrySchema).parse(JSON.parse(cached));
239
+ } catch {
240
+ /* Miss. */
241
+ }
242
+ }
243
+ if (!entries) {
244
+ const lease = await claimCacheFill(ACTIVE_CACHE_KEY, cached);
245
+ // Include scheduled entries: publish/expiry boundaries are applied at read time.
246
+ const rows = await sql<AnnouncementRow[]>`
247
+ SELECT id, version, kind, title, body, tone, published_at, expires_at,
248
+ created_at, updated_at, created_by, updated_by
249
+ FROM announcements.entries
250
+ WHERE expires_at IS NULL OR expires_at > now()
251
+ ORDER BY version DESC
252
+ `;
253
+ entries = rows.map((row) => renderAnnouncement(mapRow(row)));
254
+ await completeCacheFill(ACTIVE_CACHE_KEY, lease, JSON.stringify(entries), 300);
255
+ }
256
+ const now = Date.now();
257
+ return entries.filter((entry) => Date.parse(entry.publishedAt) <= now && (!entry.expiresAt || Date.parse(entry.expiresAt) > now));
258
+ };
259
+
205
260
  const activeForState = async (params: { state: AnnouncementCookieState; now?: Date }) =>
206
- selectVisibleForState(await listActive({ now: params.now }), params.state);
261
+ params.now
262
+ ? selectVisibleForState(await listActive({ now: params.now }), params.state)
263
+ : selectEntriesForState(await cachedActive(), params.state);
207
264
 
208
265
  export const announcements = {
209
266
  admin: {
@@ -212,6 +269,10 @@ export const announcements = {
212
269
  create,
213
270
  update,
214
271
  remove,
272
+ invalidateCache: async (actor: User | undefined) => {
273
+ if (!actor || !hasRole(actor, "admin")) throw new HTTPException(403, { message: "Administrator access required" });
274
+ await invalidateActiveCache();
275
+ },
215
276
  },
216
277
  active: {
217
278
  list: listActive,
@@ -236,7 +236,7 @@ const record = async (params: AuditRecordParams, db: AuditDb = sql): Promise<voi
236
236
  ${asString(params.error?.code ?? null)},
237
237
  ${sanitizeAuditText(params.error?.message ?? null)},
238
238
  ${asString(params.requestId)},
239
- ${JSON.stringify(metadata)}::jsonb
239
+ ${JSON.stringify(metadata)}::text::jsonb
240
240
  )
241
241
  `;
242
242
  } catch (error) {
@@ -0,0 +1,35 @@
1
+ import { requestCacheRedis } from "./request-cache-redis";
2
+
3
+ // Non-JSON markers remain compatible with older readers: they discard them and
4
+ // use Postgres. A marker can never be confused with a stored JSON setting value.
5
+ export const MISSING_SETTING = "!cloud:missing-setting:v1";
6
+ const FILL_PREFIX = "!cloud:cache-fill:v1:";
7
+ const COMPLETE = `if redis.call('GET', KEYS[1]) == ARGV[1] then
8
+ return redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
9
+ end return nil`;
10
+ const DISCARD = `if redis.call('GET', KEYS[1]) == ARGV[1] then
11
+ return redis.call('DEL', KEYS[1])
12
+ end return 0`;
13
+
14
+ /** A concurrent invalidation deletes the lease, so an old read cannot refill it. */
15
+ export const claimCacheFill = async (key: string, observed: string | null): Promise<string | null> => {
16
+ try {
17
+ if (observed?.startsWith(FILL_PREFIX)) return null;
18
+ const redis = await requestCacheRedis();
19
+ if (observed !== null) await redis.send("EVAL", [DISCARD, "1", key, observed]);
20
+ const token = FILL_PREFIX + crypto.randomUUID();
21
+ // A slow loader may still return its value, but loses permission to cache it.
22
+ return (await redis.send("SET", [key, token, "NX", "EX", "5"])) === "OK" ? token : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+
28
+ export const completeCacheFill = async (key: string, token: string | null, value: string, ttlSeconds: number): Promise<void> => {
29
+ if (!token) return;
30
+ try {
31
+ await (await requestCacheRedis()).send("EVAL", [COMPLETE, "1", key, token, value, String(ttlSeconds)]);
32
+ } catch {
33
+ // A cache failure does not change a successful authoritative read.
34
+ }
35
+ };
@@ -0,0 +1,109 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { getApp, listApps } from "../../_internal/registry";
3
+ import type { AppRegistryEntry } from "../../contracts/registry";
4
+ import { resolveAppPresentations } from "../../shared/app-presentation";
5
+ import { helpLocaleChain } from "../../shared/help";
6
+ import { logger } from "../logging";
7
+ import { HELP_RESOURCE_PAGE_SIZE, type HelpSelection, hasHelpBm25, metadata, queryHelp } from "./store";
8
+ import type { HelpReader } from "./types";
9
+
10
+ export { cleanupHelp, migrateHelp, registerHelp } from "./store";
11
+ export type { HelpArticle, HelpMatch, HelpMetadata, HelpReader, HelpReaderFactory } from "./types";
12
+
13
+ const log = logger("help");
14
+
15
+ export const createHelpReader = (
16
+ locale: string,
17
+ dependencies: {
18
+ db?: SQL;
19
+ listApps?: () => Promise<AppRegistryEntry[]>;
20
+ getApp?: (appId: string) => Promise<AppRegistryEntry | null>;
21
+ } = {},
22
+ ): HelpReader => {
23
+ const db = dependencies.db ?? sql;
24
+ const select = async (appId?: string): Promise<HelpSelection[]> => {
25
+ const apps = appId
26
+ ? [await (dependencies.getApp ?? getApp)(appId)].filter((app): app is AppRegistryEntry => app !== null)
27
+ : await (dependencies.listApps ?? listApps)();
28
+ return resolveAppPresentations(apps, locale).flatMap((app) =>
29
+ app.help
30
+ ? [
31
+ {
32
+ app_id: app.id,
33
+ app_name: app.name,
34
+ manifest_hash: app.help.manifestHash,
35
+ locales: helpLocaleChain(locale, app.help.baseLocale),
36
+ },
37
+ ]
38
+ : [],
39
+ );
40
+ };
41
+ return {
42
+ async manifest(appId) {
43
+ const app = await (dependencies.getApp ?? getApp)(appId);
44
+ if (!app?.help) return null;
45
+ const [corpus] = await db`SELECT 1 FROM help.corpora WHERE app_id=${app.id} AND manifest_hash=${app.help.manifestHash}`;
46
+ if (!corpus) return null;
47
+ const rows = await queryHelp(
48
+ [
49
+ {
50
+ app_id: app.id,
51
+ app_name: app.name,
52
+ manifest_hash: app.help.manifestHash,
53
+ locales: helpLocaleChain(locale, app.help.baseLocale),
54
+ },
55
+ ],
56
+ {},
57
+ db,
58
+ );
59
+ return {
60
+ ...app.help,
61
+ locale,
62
+ documents: rows
63
+ .sort((a, b) => a.sort_order - b.sort_order || a.title.localeCompare(b.title))
64
+ .map((row) => ({
65
+ id: row.document_id,
66
+ title: row.title,
67
+ icon: row.icon ?? undefined,
68
+ description: row.description ?? undefined,
69
+ order: row.sort_order,
70
+ locale: row.locale,
71
+ searchUrl: `/api/help/v1/${encodeURIComponent(appId)}/search`,
72
+ url: `/api/help/v1/${encodeURIComponent(appId)}/documents/${encodeURIComponent(row.document_id)}`,
73
+ })),
74
+ };
75
+ },
76
+ async list(cursor = "") {
77
+ return (await queryHelp(await select(), { cursor, limit: HELP_RESOURCE_PAGE_SIZE + 1 }, db)).map(metadata);
78
+ },
79
+ async read(input) {
80
+ const [row] = await queryHelp(await select(input.appId), { documentId: input.documentId, body: true, limit: 1 }, db);
81
+ return row?.markdown === undefined ? null : { ...metadata(row), markdown: row.markdown };
82
+ },
83
+ async search(input) {
84
+ const query = input.query.trim().slice(0, 200);
85
+ if (!query) return [];
86
+ const selection = await select(input.appId);
87
+ if (!selection.length) return [];
88
+ const options = { query, limit: Math.max(1, Math.min(Math.floor(Number.isFinite(input.limit) ? input.limit! : 10), 25)) };
89
+ const bm25 = await hasHelpBm25(db);
90
+ const rows = await queryHelp(selection, { ...options, bm25 }, db).catch((error: unknown) => {
91
+ // Only missing optional SQL objects qualify for fallback, not connection/query errors.
92
+ const code = error && typeof error === "object" ? (Reflect.get(error, "errno") ?? Reflect.get(error, "code")) : undefined;
93
+ if (!bm25 || !["42883", "42704", "42P01"].includes(String(code))) throw error;
94
+ log.warn("BM25 unavailable; using native Help search");
95
+ return queryHelp(selection, options, db);
96
+ });
97
+ return rows.map((row) => ({
98
+ kind: "help",
99
+ appId: row.app_id,
100
+ appName: row.app_name,
101
+ locale: row.locale,
102
+ documentId: row.document_id,
103
+ title: row.title,
104
+ description: row.description ?? undefined,
105
+ }));
106
+ },
107
+ };
108
+ };
109
+ export { startHelpMaintenance } from "./maintenance";
@@ -0,0 +1,25 @@
1
+ import { logger } from "../logging";
2
+ import { cleanupHelp } from "./store";
3
+
4
+ const log = logger("help:maintenance");
5
+ /** Core owns the timer; stop waits for the current bounded deletion. */
6
+ export const startHelpMaintenance = (): (() => Promise<void>) => {
7
+ let stopped = false;
8
+ let inFlight: Promise<void> | undefined;
9
+ let timer: ReturnType<typeof setTimeout>;
10
+ const run = () => {
11
+ inFlight = cleanupHelp()
12
+ .catch((error) => {
13
+ log.error("Expired Help cleanup failed", { error: error instanceof Error ? error.message : String(error) });
14
+ })
15
+ .finally(() => {
16
+ if (!stopped) timer = setTimeout(run, 60_000);
17
+ });
18
+ };
19
+ timer = setTimeout(run, 60_000);
20
+ return async () => {
21
+ stopped = true;
22
+ clearTimeout(timer);
23
+ await inFlight;
24
+ };
25
+ };
@@ -0,0 +1,180 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
3
+ import type { HelpCorpus, HelpMetadata } from "./types";
4
+
5
+ // One expired app lease of grace after an app can no longer be discovered.
6
+ export const HELP_RETENTION_MS = APP_REGISTRY_TTL_MS * 2;
7
+ export const HELP_RESOURCE_PAGE_SIZE = 100;
8
+ const searchConfigs = ["english", "german", "simple"] as const;
9
+
10
+ export const migrateHelp = async (db: SQL = sql): Promise<void> => {
11
+ await db.begin(async (tx) => {
12
+ await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:help:migrate'))`;
13
+ await tx`CREATE SCHEMA IF NOT EXISTS help`.simple();
14
+ await tx`CREATE TABLE IF NOT EXISTS help.corpora (
15
+ app_id text NOT NULL, manifest_hash text NOT NULL, base_locale text NOT NULL,
16
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
17
+ PRIMARY KEY (app_id, manifest_hash)
18
+ )`.simple();
19
+ await tx`CREATE INDEX IF NOT EXISTS help_expiry_idx ON help.corpora(last_seen_at)`.simple();
20
+ await tx`CREATE TABLE IF NOT EXISTS help.documents (
21
+ app_id text NOT NULL, manifest_hash text NOT NULL, locale text NOT NULL, document_id text NOT NULL,
22
+ title text NOT NULL, description text, icon text, sort_order integer NOT NULL,
23
+ markdown text NOT NULL, search_text text NOT NULL, search_config text NOT NULL,
24
+ search_document tsvector NOT NULL,
25
+ PRIMARY KEY (app_id, manifest_hash, locale, document_id),
26
+ FOREIGN KEY (app_id, manifest_hash) REFERENCES help.corpora ON DELETE CASCADE
27
+ )`.simple();
28
+ await tx`CREATE INDEX IF NOT EXISTS help_search_idx ON help.documents USING gin(search_document)`.simple();
29
+ });
30
+ // The operator enables the extension; Help never installs server extensions.
31
+ const [extension] = await db<{ installed: boolean }[]>`SELECT EXISTS (
32
+ SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed`;
33
+ if (extension?.installed) {
34
+ try {
35
+ for (const config of searchConfigs) {
36
+ await db
37
+ .unsafe(`CREATE INDEX IF NOT EXISTS help_bm25_${config}_idx ON help.documents
38
+ USING bm25(search_text) WITH (text_config='${config}') WHERE search_config='${config}'`)
39
+ .simple();
40
+ }
41
+ } catch (error) {
42
+ console.warn("Optional Help BM25 indexes unavailable; native search remains active", error);
43
+ }
44
+ }
45
+ };
46
+
47
+ /** Commit the complete source once; subsequent app heartbeats only renew its lease. */
48
+ export const registerHelp = async (corpus: HelpCorpus, db: SQL = sql): Promise<void> => {
49
+ await db.begin(async (tx) => {
50
+ await tx`SET LOCAL statement_timeout = '10s'`.simple();
51
+ const renewed = await tx`UPDATE help.corpora SET last_seen_at = clock_timestamp()
52
+ WHERE app_id = ${corpus.appId} AND manifest_hash = ${corpus.manifestHash} RETURNING app_id`;
53
+ if (renewed.length) return;
54
+ const inserted = await tx`INSERT INTO help.corpora(app_id, manifest_hash, base_locale)
55
+ VALUES (${corpus.appId}, ${corpus.manifestHash}, ${corpus.baseLocale})
56
+ ON CONFLICT (app_id, manifest_hash) DO UPDATE SET last_seen_at = clock_timestamp()
57
+ RETURNING (xmax = 0) AS inserted`;
58
+ if (!inserted[0]?.inserted) return;
59
+ for (const [locale, documents] of [[corpus.baseLocale, corpus.documents] as const, ...Object.entries(corpus.documentsByLocale)]) {
60
+ const config = locale.split("-")[0] === "de" ? "german" : locale.split("-")[0] === "en" ? "english" : "simple";
61
+ // One bounded article per statement; no duplicate whole-corpus JSON transport.
62
+ for (const document of documents) {
63
+ const text = `${document.title}\n${document.description ?? ""}\n${document.searchText}`;
64
+ await tx`INSERT INTO help.documents(app_id, manifest_hash, locale, document_id, title, description, icon,
65
+ sort_order, markdown, search_text, search_config, search_document)
66
+ VALUES (${corpus.appId}, ${corpus.manifestHash}, ${locale}, ${document.id}, ${document.title},
67
+ ${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.markdown}, ${text}, ${config},
68
+ setweight(to_tsvector(${config}::regconfig, ${document.id + " " + document.title}), 'A') ||
69
+ setweight(to_tsvector(${config}::regconfig, ${document.description ?? ""}), 'B') ||
70
+ setweight(to_tsvector(${config}::regconfig, ${document.searchText}), 'D'))`;
71
+ }
72
+ }
73
+ });
74
+ };
75
+
76
+ /** Row locks serialize cleanup with publication/renewal. Delete at most one corpus per tick. */
77
+ export const cleanupHelp = async (db: SQL = sql): Promise<void> => {
78
+ await db.begin(async (tx) => {
79
+ await tx`SET LOCAL statement_timeout = '10s'`.simple();
80
+ await tx`DELETE FROM help.corpora WHERE (app_id, manifest_hash) IN (
81
+ SELECT app_id, manifest_hash FROM help.corpora
82
+ WHERE last_seen_at < now() - ${HELP_RETENTION_MS} * interval '1 millisecond'
83
+ ORDER BY last_seen_at LIMIT 1 FOR UPDATE SKIP LOCKED
84
+ )`;
85
+ });
86
+ };
87
+
88
+ export type HelpSelection = { app_id: string; app_name: string; manifest_hash: string; locales: string[] };
89
+ type Row = {
90
+ app_id: string;
91
+ app_name: string;
92
+ manifest_hash: string;
93
+ locale: string;
94
+ document_id: string;
95
+ title: string;
96
+ description: string | null;
97
+ icon: string | null;
98
+ sort_order: number;
99
+ markdown?: string;
100
+ };
101
+ export const metadata = (row: Row): HelpMetadata => ({
102
+ appId: row.app_id,
103
+ appName: row.app_name,
104
+ manifestHash: row.manifest_hash,
105
+ locale: row.locale,
106
+ documentId: row.document_id,
107
+ title: row.title,
108
+ description: row.description ?? undefined,
109
+ icon: row.icon ?? undefined,
110
+ order: row.sort_order,
111
+ });
112
+
113
+ export const hasHelpBm25 = async (db: SQL = sql): Promise<boolean> => {
114
+ const [row] = await db<{ ready: boolean }[]>`SELECT
115
+ EXISTS (SELECT 1 FROM pg_extension WHERE extname='pg_textsearch') AND
116
+ (SELECT count(*) = 3 FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid JOIN pg_am a ON a.oid=c.relam
117
+ WHERE c.oid IN (to_regclass('help.help_bm25_english_idx'), to_regclass('help.help_bm25_german_idx'),
118
+ to_regclass('help.help_bm25_simple_idx')) AND i.indisvalid AND a.amname='bm25') AS ready`;
119
+ return row?.ready === true;
120
+ };
121
+
122
+ /** Locale precedence is applied before matching, ranking and limiting, including partial translations. */
123
+ export const queryHelp = async (
124
+ selection: HelpSelection[],
125
+ input: {
126
+ query?: string;
127
+ documentId?: string;
128
+ body?: boolean;
129
+ limit?: number;
130
+ cursor?: string;
131
+ bm25?: boolean;
132
+ },
133
+ db: SQL = sql,
134
+ ): Promise<Row[]> => {
135
+ if (!selection.length) return [];
136
+ const ranked = input.query !== undefined;
137
+ const nativeRank = `ts_rank_cd(d.search_document, websearch_to_tsquery(d.search_config::regconfig, $2))`;
138
+ const bm25Rank = `CASE d.search_config ${searchConfigs
139
+ .map((config) => `WHEN '${config}' THEN d.search_text <@> to_bm25query($2, 'help.help_bm25_${config}_idx')`)
140
+ .join(" ")} END`;
141
+ const matching = searchConfigs
142
+ .map((config) => `(d.search_config='${config}' AND d.search_document @@ websearch_to_tsquery('${config}', $2))`)
143
+ .join(" OR ");
144
+ const pending = db.unsafe<Row[]>(
145
+ `WITH active AS (
146
+ SELECT * FROM jsonb_to_recordset(($1::text)::jsonb) AS a(app_id text, app_name text, manifest_hash text, locales text[])
147
+ ) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order
148
+ ${input.body ? ", d.markdown" : ""}
149
+ FROM help.documents d JOIN active a ON a.app_id=d.app_id AND a.manifest_hash=d.manifest_hash
150
+ WHERE length($2::text) >= 0 AND d.locale=ANY(a.locales)
151
+ AND NOT EXISTS (SELECT 1 FROM help.documents preferred
152
+ WHERE preferred.app_id=d.app_id AND preferred.manifest_hash=d.manifest_hash AND preferred.document_id=d.document_id
153
+ AND preferred.locale=ANY(a.locales) AND array_position(a.locales, preferred.locale)<array_position(a.locales, d.locale))
154
+ AND ($3::text IS NULL OR d.document_id=$3)
155
+ AND ($4::text IS NULL OR ('cloud://help/' || d.app_id || '/' || d.document_id) COLLATE "C" > $4)
156
+ ${ranked ? `AND (lower(d.document_id)=lower($2) OR lower(d.title)=lower($2) OR ${matching})` : ""}
157
+ ORDER BY ${
158
+ ranked
159
+ ? `(lower(d.document_id)=lower($2) OR lower(d.title)=lower($2)) DESC,
160
+ ${input.bm25 ? bm25Rank + " ASC," : ""} ${nativeRank} DESC,`
161
+ : ""
162
+ }
163
+ d.app_id COLLATE "C", ${input.cursor !== undefined ? "" : "d.sort_order,"} d.document_id COLLATE "C"
164
+ ${input.limit !== undefined ? "LIMIT $5" : ""}`,
165
+ [
166
+ JSON.stringify(selection),
167
+ input.query ?? "",
168
+ input.documentId ?? null,
169
+ input.cursor ?? null,
170
+ ...(input.limit !== undefined ? [input.limit] : []),
171
+ ],
172
+ );
173
+ // Match the app publication write deadline and bound abandoned reads at the driver.
174
+ const timeout = setTimeout(() => pending.cancel(), 10_000);
175
+ try {
176
+ return await pending;
177
+ } finally {
178
+ clearTimeout(timeout);
179
+ }
180
+ };
@@ -0,0 +1,46 @@
1
+ import type { AppRegistryHelpSummary } from "../../contracts/registry";
2
+ import type { HelpDocumentManifest } from "../../shared/help";
3
+
4
+ export type HelpDocument = {
5
+ id: string;
6
+ title: string;
7
+ icon?: string;
8
+ description?: string;
9
+ order: number;
10
+ markdown: string;
11
+ searchText: string;
12
+ };
13
+
14
+ export type HelpCorpus = {
15
+ appId: string;
16
+ manifestHash: string;
17
+ baseLocale: string;
18
+ documents: readonly HelpDocument[];
19
+ documentsByLocale: Readonly<Record<string, readonly HelpDocument[]>>;
20
+ };
21
+
22
+ export type HelpMetadata = {
23
+ appId: string;
24
+ appName: string;
25
+ manifestHash: string;
26
+ locale: string;
27
+ documentId: string;
28
+ title: string;
29
+ description?: string;
30
+ icon?: string;
31
+ order: number;
32
+ };
33
+ export type HelpArticle = HelpMetadata & { markdown: string };
34
+ export type HelpMatch = Pick<HelpMetadata, "appId" | "appName" | "locale" | "documentId" | "title" | "description"> & { kind: "help" };
35
+ export type HelpSearchInput = { query: string; appId?: string; limit?: number };
36
+ export type HelpReadInput = { appId: string; documentId: string };
37
+ export type HelpManifestResult = AppRegistryHelpSummary & { locale: string; documents: readonly HelpDocumentManifest[] };
38
+
39
+ /** One request's language; operations resolve the current published app versions. */
40
+ export type HelpReader = {
41
+ manifest(appId: string): Promise<HelpManifestResult | null>;
42
+ list(cursor?: string): Promise<HelpMetadata[]>;
43
+ search(input: HelpSearchInput): Promise<HelpMatch[]>;
44
+ read(input: HelpReadInput): Promise<HelpArticle | null>;
45
+ };
46
+ export type HelpReaderFactory = (locale: string) => HelpReader;