@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.
- package/README.md +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- 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 {
|
|
16
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
entries:
|
|
205
|
+
const selectEntriesForState = <T extends { kind: AnnouncementEntry["kind"]; version: number }>(
|
|
206
|
+
entries: T[],
|
|
187
207
|
state: AnnouncementCookieState,
|
|
188
|
-
)
|
|
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
|
-
|
|
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;
|