Compare commits

..
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 08733bf74d feat(models): raise the pool ceiling and report its state when acquisition is slow
Two changes that go together, because the first is untestable without the second.

`max_size` goes 20 → 50. The comment justifying 20 was out of date: it cited the
256 open-file soft limit macOS gives GUI apps, but `run()` has since called
`rlimit::increase_nofile_limit(10240)` before any pool is opened, so the ceiling
it was sized against is gone. 50 WAL connections is roughly 150 descriptors
against 10240, and only `min_idle` is opened up front — r2d2 creates the rest on
demand — so the idle cost is unchanged. The comment now says what actually
bounds the number: commands nest a couple of connections each, and since #542
every frontend command dispatches as its own task, so they arrive together.

On its own that is a guess with no way to check itself. A pool that cannot hand
out a connection fails with `Error(None)` and nothing else, whether every
connection is checked out or none could be opened — unrelated problems with
identical symptoms, and raising the ceiling only helps the first. So both pools
now acquire through one helper that times the wait, warns past a second, and
folds the pool's state into `Error::PoolTimeout`. The error a command returns
now says which failure it was, which is what the next report needs to be worth
anything.

Blob pool sizing is untouched; nothing points at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:09:02 -07:00
Gregory SchierandClaude Opus 5 1655aaa507 fix(send): stop the HTTP response-event writer panicking on a pool timeout
The event task spawned per send still reached `connect().unwrap()`. A pool
timeout there panicked the task and took every later timeline event for that
send with it, straight past the `warn!` branch already sitting underneath. The
acquisition now joins the upsert in the same `if let Err`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:09:02 -07:00
Gregory SchierandClaude Opus 5 c9e884d4fe fix(models): return a database error instead of panicking when the pool is exhausted
Two users on 2026.7.x reported the app going half-dead: local editing kept
working, but Send did nothing and every encrypted secret rendered blank until
they restarted. The webview console carried a Rust task panic —

    Delete task failed: task 1314 panicked with message
    "Failed to get new DB connection from the pool: Error(None)"

`Error(None)` is r2d2's acquire timeout. `QueryManager::connect` and `with_tx`
turned that timeout into `expect`, so a pool that was momentarily full took down
whatever task asked for a connection instead of reporting a failure the caller
could show, retry, or ignore. Every DB-backed path shares those two functions,
which is why one stalled pool reads as "Send is dead and the secrets are gone".

Acquisition is now fallible end to end: `connect()` and `BlobManager::connect()`
return `Result`, and so do the `db()`/`blobs()` accessors on every host. Call
sites propagate with `?` where they already returned a result; the fire-and-forget
writers (streaming events, the model-change poller) log and carry on. `with_conn`
had no callers and only added a third panic, so it is gone.

Two places held a pooled connection across work that has nothing to do with the
database and can block for a long time — the OS keyring in
`EncryptionManager::get_workspace_key`, and the sync directory walk in
`cmd_sync_calculate`. Both now hand the connection back first. Those are the
paths behind the two visible symptoms.

Not fixed here: the gRPC streaming block in yaak-app-client still `.unwrap()`s
its database writes, as it did before, so acquisition failures panic there too.
`ProxyQueryManager` in the separate proxy app keeps its own `expect`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:09:02 -07:00
Gregory SchierandClaude Opus 5 7f8b7bf567 feat(import): remember the user's import selection (#619)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:26:43 -07:00
Gregory Schier 096e83a7bd chore(deps): patch open security advisories across npm deps (#630) 2026-09-10 14:34:00 -07:00
gschier 89ce84cefe Deploying to main from @ mountain-loop/yaak@d5d2a64c32 🚀 2026-09-10 15:37:06 +00:00
Gregory Schier d5d2a64c32 fix(cli): focus the agent skill description on when to use it (#625) 2026-09-09 08:50:26 -07:00
86 changed files with 3437 additions and 1413 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
Generated
+2
View File
@@ -11228,6 +11228,7 @@ dependencies = [
"md5 0.8.0",
"rusqlite",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.17",
"tokio",
@@ -11594,6 +11595,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.17",
"ts-rs",
"urlencoding",
+1 -1
View File
@@ -17,7 +17,7 @@
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
+196 -103
View File
@@ -6,7 +6,7 @@ import {
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
@@ -260,14 +260,17 @@ function LoadedImportDataDialog({
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
// updates toggle together, while removals only ever cascade beneath a removed folder.
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
const targets = new Set(
collectItems(node)
.filter((i) => togglesWith(node.data, i))
.map((i) => i.modelId),
);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
const targets = new Set(togglableItems(node).map((i) => i.modelId));
if (checked && node.data.kind === "item") {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data.item, byId)) {
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
@@ -277,25 +280,16 @@ function LoadedImportDataDialog({
);
};
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
// new folder can't exist, and a removed folder takes its contents with it.
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null || parent.model !== "folder") break;
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
if (item.action !== "delete") continue;
for (const parent of ancestorsOf(item, byId)) {
if (parent.action === "delete" && parent.selected) {
disabled.add(item.modelId);
}
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
parentId = parent.parentId;
}
}
return disabled;
@@ -314,18 +308,26 @@ function LoadedImportDataDialog({
return item.selected;
}).length;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
// The row's label carries what kind of destination it is, so the value can just be its name
const [destinationLabel, destinationValue] = ((): [string, string] => {
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")];
}
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
@@ -335,11 +337,9 @@ function LoadedImportDataDialog({
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
model: "workspace",
modelId: existing?.id ?? planned?.id ?? "workspace",
name: existing?.name ?? planned?.name ?? "New workspace",
selected: true,
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
isNew: planDestination.type === "new_workspace",
},
children: itemTree,
};
@@ -349,40 +349,42 @@ function LoadedImportDataDialog({
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
@@ -402,7 +404,7 @@ function LoadedImportDataDialog({
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Apply"}
: "Done"}
</Button>
</HStack>
</VStack>
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({
}
function ImportTreeRow({
item,
row,
onResolveConflict,
}: {
item: ImportPlanItem;
row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return (
<>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
<Icon
color="secondary"
icon={
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
}
/>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0 flex items-center gap-1.5">
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
@@ -577,29 +590,49 @@ function ImportTreeRow({
{ value: "take_source", label: "Take source" },
]}
/>
<IconTooltip content={actionHelp(item)} iconSize="sm" />
</div>
) : (
actionLabel(item) && (
<InlineCode
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
>
{actionLabel(item)}
<IconTooltip content={actionHelp(item)} iconSize="xs" />
</InlineCode>
/>
)
)}
</>
);
}
function ActionChip({
label,
help,
className,
}: {
label: string;
help: string | null;
className?: string;
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
@@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null {
return "removed";
case "keep_local":
return "edited";
case "ignored":
return "ignored";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Added since the last import";
return "Not in this workspace yet";
case "update":
return "Changed since the last import";
return item.reason === "moved_into_ignored_folder"
? "Moved into a folder that isn't imported. Importing that folder brings it along"
: help("Changed in the source since the last import");
case "delete":
return "Deleted since the last import";
return "Gone from the source since the last import. Checking it deletes it here";
case "keep_local":
return "Local edits made since the last import. Importing will revert them if checked";
return help("Changed here since the last import. Checking it reverts to the source");
case "conflict":
return "Changed both here and in the file since the last import";
return help("Changed here and in the source since the last import");
case "ignored":
return "Not in this workspace. Imports leave it alone until you check it";
default:
return null;
}
}
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
/**
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
* workspace's environments sit in are headings: they aggregate their children and decide nothing
* themselves.
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
@@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
}
}
const foldersFirst = (list: ImportPlanItem[]) => [
const byKind = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "environment"),
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "folder"),
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId,
data: item,
data: { kind: "item", item },
children: seen.has(item.modelId)
? []
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
return foldersFirst(roots).map((r) => toNode(r, new Set()));
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
// the base one, not its child — so a heading groups them into one thing to turn on and off.
const environments = roots.filter((i) => i.model === "environment");
const others = byKind(roots.filter((i) => i.model !== "environment"));
const nodes = others.map((r) => toNode(r, new Set()));
if (environments.length === 0) return nodes;
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
];
}
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
return [node.data, ...node.children.flatMap(collectItems)];
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectRows)];
}
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
function isMissingFolder(item: ImportPlanItem): boolean {
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
}
/**
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
* (deletions, reverting local edits) never ride along with a parent toggle.
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
* along with a parent's.
*/
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
if (item.model === "workspace") return false;
if (root.action === "delete") return item.action === "delete";
if (item.action === "keep_local") {
return root.modelId === item.modelId && item.model !== "folder";
}
return item.action === "create" || item.action === "update";
function togglableItem(row: TreeRow): ImportPlanItem | null {
if (row.kind !== "item") return null;
const { item } = row;
return item.action === "unchanged" || item.action === "conflict" ? null : item;
}
function nodeCheckedStatus(
node: CheckboxTreeNode<ImportPlanItem>,
): boolean | "indeterminate" | "hidden" {
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
return collectRows(node)
.map(togglableItem)
.filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
@@ -21,6 +21,8 @@ interface Props<T> {
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
@@ -29,7 +31,9 @@ interface Props<T> {
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(false);
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
interface Props<T extends string> {
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return (
<div className="w-full grid">
<Label
htmlFor={id.current}
help={help}
help={hideLabel ? undefined : help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
}
}}
>
{options.map((o) => {
{options.map((o, i) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
return (
<Button
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{o.label}
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
+7 -14
View File
@@ -1,19 +1,12 @@
---
name: use-yaak
description: >
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
folders, environments and variables, author HTTP requests, configure
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
send them individually or a whole folder/workspace at once, chain one
request's response into the next, and import existing APIs from OpenAPI,
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
API requests for reuse, to set up API requests for manual testing, to add auth
to a saved request, to turn an OpenAPI or Postman collection into runnable
requests, or to run a saved request suite against staging versus production.
Prefer this over one-off `curl` commands whenever the requests should be
saved, reused, shared, or run as a set.
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
---
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
Two consequences worth holding onto. Requests you create are permanent user data
in an app they use, not scratch files, so name them the way the user would and
clean up anything created just to test. And because the app is right there, the
CLI is usually the wrong place to *read* a response in detail; it is the right
CLI is usually the wrong place to _read_ a response in detail; it is the right
place to build, organize, and run requests.
## The CLI describes itself
@@ -22,6 +22,7 @@ fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "cookie-jar list")?;
let cookie_jars = ctx
.db()
.map_err(|e| e.to_string())?
.list_cookie_jars(&workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?;
@@ -50,6 +50,7 @@ fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?;
let environments = ctx
.db()
.map_err(|e| e.to_string())?
.list_environments_ensure_base(&workspace_id)
.map_err(|e| format!("Failed to list environments: {e}"))?;
@@ -66,6 +67,7 @@ fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
fn show(ctx: &CliContext, environment_id: &str) -> CommandResult {
let environment = ctx
.db()
.map_err(|e| e.to_string())?
.get_environment(environment_id)
.map_err(|e| format!("Failed to get environment: {e}"))?;
let output = serde_json::to_string_pretty(&environment)
@@ -112,6 +114,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_environment(&environment, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create environment: {e}"))?;
@@ -134,6 +137,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_environment(&environment, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create environment: {e}"))?;
@@ -147,12 +151,14 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let existing = ctx
.db()
.map_err(|e| e.to_string())?
.get_environment(&id)
.map_err(|e| format!("Failed to get environment for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?;
let saved = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_environment(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update environment: {e}"))?;
@@ -168,6 +174,7 @@ fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {
let deleted = ctx
.db()
.map_err(|e| e.to_string())?
.delete_environment_by_id(environment_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete environment: {e}"))?;
+19 -6
View File
@@ -48,8 +48,11 @@ fn schema(pretty: bool) -> CommandResult {
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "folder list")?;
let folders =
ctx.db().list_folders(&workspace_id).map_err(|e| format!("Failed to list folders: {e}"))?;
let folders = ctx
.db()
.map_err(|e| e.to_string())?
.list_folders(&workspace_id)
.map_err(|e| format!("Failed to list folders: {e}"))?;
if folders.is_empty() {
println!("No folders found in workspace {}", workspace_id);
} else {
@@ -61,8 +64,11 @@ fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
}
fn show(ctx: &CliContext, folder_id: &str) -> CommandResult {
let folder =
ctx.db().get_folder(folder_id).map_err(|e| format!("Failed to get folder: {e}"))?;
let folder = ctx
.db()
.map_err(|e| e.to_string())?
.get_folder(folder_id)
.map_err(|e| format!("Failed to get folder: {e}"))?;
let output = serde_json::to_string_pretty(&folder)
.map_err(|e| format!("Failed to serialize folder: {e}"))?;
println!("{output}");
@@ -103,6 +109,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_folder(&folder, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create folder: {e}"))?;
@@ -119,6 +126,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_folder(&folder, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create folder: {e}"))?;
@@ -130,12 +138,16 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let patch = parse_required_json(json, json_input, "folder update")?;
let id = require_id(&patch, "folder update")?;
let existing =
ctx.db().get_folder(&id).map_err(|e| format!("Failed to get folder for update: {e}"))?;
let existing = ctx
.db()
.map_err(|e| e.to_string())?
.get_folder(&id)
.map_err(|e| format!("Failed to get folder for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?;
let saved = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_folder(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update folder: {e}"))?;
@@ -151,6 +163,7 @@ fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {
let deleted = ctx
.db()
.map_err(|e| e.to_string())?
.delete_folder_by_id(folder_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete folder: {e}"))?;
@@ -47,6 +47,7 @@ async fn import(
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db()
.map_err(|e| e.to_string())?
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
@@ -113,6 +114,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
if keep_local > 0 {
parts.push(format!("{keep_local} with local edits"));
}
let ignored = count(ImportPlanAction::Ignored);
if ignored > 0 {
parts.push(format!("{ignored} ignored"));
}
let unchanged = count(ImportPlanAction::Unchanged);
if unchanged > 0 {
parts.push(format!("{unchanged} unchanged"));
@@ -142,8 +147,11 @@ fn resolve_export_workspace_ids(
all: bool,
) -> CommandResult<Vec<String>> {
if all {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
let workspaces = ctx
.db()
.map_err(|e| e.to_string())?
.list_workspaces()
.map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
return Err("No workspaces found to export".to_string());
}
@@ -156,6 +164,7 @@ fn resolve_export_workspace_ids(
for workspace_id in &workspace_ids {
ctx.db()
.map_err(|e| e.to_string())?
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
@@ -349,6 +349,7 @@ async fn install_from_directory(context: &CliContext, source: &str) -> CommandRe
let plugin = context
.db()
.map_err(|e| e.to_string())?
.upsert_plugin(
&Plugin {
directory: plugin_dir_str,
+17 -4
View File
@@ -71,6 +71,7 @@ fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "request list")?;
let requests = ctx
.db()
.map_err(|e| e.to_string())?
.list_http_requests(&workspace_id)
.map_err(|e| format!("Failed to list requests: {e}"))?;
if requests.is_empty() {
@@ -425,6 +426,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_http_request(&request, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create request: {e}"))?;
@@ -444,6 +446,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_http_request(&request, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create request: {e}"))?;
@@ -458,12 +461,14 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let existing = ctx
.db()
.map_err(|e| e.to_string())?
.get_http_request(&id)
.map_err(|e| format!("Failed to get request for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "request update")?;
let saved = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_http_request(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update request: {e}"))?;
@@ -472,8 +477,11 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
}
fn show(ctx: &CliContext, request_id: &str) -> CommandResult {
let request =
ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
let request = ctx
.db()
.map_err(|e| e.to_string())?
.get_http_request(request_id)
.map_err(|e| format!("Failed to get request: {e}"))?;
let output = serde_json::to_string_pretty(&request)
.map_err(|e| format!("Failed to serialize request: {e}"))?;
println!("{output}");
@@ -488,6 +496,7 @@ fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {
let deleted = ctx
.db()
.map_err(|e| e.to_string())?
.delete_http_request_by_id(request_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete request: {e}"))?;
println!("Deleted request: {}", deleted.id);
@@ -502,8 +511,11 @@ pub async fn send_request_by_id(
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let request =
ctx.db().get_any_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
let request = ctx
.db()
.map_err(|e| e.to_string())?
.get_any_request(request_id)
.map_err(|e| format!("Failed to get request: {e}"))?;
match request {
AnyRequest::HttpRequest(http_request) => {
send_http_request_by_id(
@@ -611,6 +623,7 @@ pub(crate) fn resolve_cookie_jar_id(
let default_cookie_jar = ctx
.db()
.map_err(|e| e.to_string())?
.list_cookie_jars(workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?
.into_iter()
+9 -4
View File
@@ -27,13 +27,14 @@ pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 {
/// Accepts a response ID, or a request ID meaning "the latest response for that request".
fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<HttpResponse> {
if let Ok(response) = ctx.db().get_http_response(id) {
if let Ok(response) = ctx.db().map_err(|e| e.to_string())?.get_http_response(id) {
return Ok(response);
}
if ctx.db().get_http_request(id).is_ok() {
if ctx.db().map_err(|e| e.to_string())?.get_http_request(id).is_ok() {
return ctx
.db()
.map_err(|e| e.to_string())?
.list_http_responses_for_request(id, Some(1))
.map_err(|e| format!("Failed to list responses: {e}"))?
.into_iter()
@@ -47,13 +48,15 @@ fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<HttpResponse> {
fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult {
// An explicit request ID scopes to that request; anything else lists the workspace.
let responses = match id {
Some(id) if ctx.db().get_http_request(id).is_ok() => ctx
Some(id) if ctx.db().map_err(|e| e.to_string())?.get_http_request(id).is_ok() => ctx
.db()
.map_err(|e| e.to_string())?
.list_http_responses_for_request(id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?,
other => {
let workspace_id = resolve_workspace_id(ctx, other, "response list")?;
ctx.db()
.map_err(|e| e.to_string())?
.list_http_responses(&workspace_id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?
}
@@ -112,13 +115,14 @@ fn body(ctx: &CliContext, id: &str) -> CommandResult {
fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
// Deleting is scoped to a whole request or workspace, since a single stored
// response is rarely the thing someone wants to remove.
if ctx.db().get_http_request(id).is_ok() {
if ctx.db().map_err(|e| e.to_string())?.get_http_request(id).is_ok() {
if !yes && !confirm_delete("responses for request", id) {
println!("Aborted");
return Ok(());
}
let count = ctx
.db()
.map_err(|e| e.to_string())?
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for request {id}");
@@ -132,6 +136,7 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
}
let count = ctx
.db()
.map_err(|e| e.to_string())?
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for workspace {workspace_id}");
+9 -3
View File
@@ -34,7 +34,7 @@ async fn send_target(
) -> Result<(), String> {
let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential };
if let Ok(request) = ctx.db().get_any_request(&args.id) {
if let Ok(request) = ctx.db().map_err(|e| e.to_string())?.get_any_request(&args.id) {
let workspace_id = match &request {
AnyRequest::HttpRequest(r) => r.workspace_id.clone(),
AnyRequest::GrpcRequest(r) => r.workspace_id.clone(),
@@ -53,7 +53,7 @@ async fn send_target(
.await;
}
if let Ok(folder) = ctx.db().get_folder(&args.id) {
if let Ok(folder) = ctx.db().map_err(|e| e.to_string())?.get_folder(&args.id) {
let resolved_cookie_jar_id =
request::resolve_cookie_jar_id(ctx, &folder.workspace_id, cookie_jar_id)?;
@@ -74,7 +74,7 @@ async fn send_target(
.await;
}
if let Ok(workspace) = ctx.db().get_workspace(&args.id) {
if let Ok(workspace) = ctx.db().map_err(|e| e.to_string())?.get_workspace(&args.id) {
let resolved_cookie_jar_id =
request::resolve_cookie_jar_id(ctx, &workspace.id, cookie_jar_id)?;
@@ -103,6 +103,7 @@ fn collect_folder_request_ids(ctx: &CliContext, folder_id: &str) -> Result<Vec<S
let mut http_ids = ctx
.db()
.map_err(|e| e.to_string())?
.list_http_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list HTTP requests in folder: {e}"))?
.into_iter()
@@ -112,6 +113,7 @@ fn collect_folder_request_ids(ctx: &CliContext, folder_id: &str) -> Result<Vec<S
let mut grpc_ids = ctx
.db()
.map_err(|e| e.to_string())?
.list_grpc_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list gRPC requests in folder: {e}"))?
.into_iter()
@@ -121,6 +123,7 @@ fn collect_folder_request_ids(ctx: &CliContext, folder_id: &str) -> Result<Vec<S
let mut websocket_ids = ctx
.db()
.map_err(|e| e.to_string())?
.list_websocket_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list WebSocket requests in folder: {e}"))?
.into_iter()
@@ -139,6 +142,7 @@ fn collect_workspace_request_ids(
let mut http_ids = ctx
.db()
.map_err(|e| e.to_string())?
.list_http_requests(workspace_id)
.map_err(|e| format!("Failed to list HTTP requests in workspace: {e}"))?
.into_iter()
@@ -148,6 +152,7 @@ fn collect_workspace_request_ids(
let mut grpc_ids = ctx
.db()
.map_err(|e| e.to_string())?
.list_grpc_requests(workspace_id)
.map_err(|e| format!("Failed to list gRPC requests in workspace: {e}"))?
.into_iter()
@@ -157,6 +162,7 @@ fn collect_workspace_request_ids(
let mut websocket_ids = ctx
.db()
.map_err(|e| e.to_string())?
.list_websocket_requests(workspace_id)
.map_err(|e| format!("Failed to list WebSocket requests in workspace: {e}"))?
.into_iter()
+11 -2
View File
@@ -43,8 +43,11 @@ fn schema(pretty: bool) -> CommandResult {
}
fn list(ctx: &CliContext) -> CommandResult {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
let workspaces = ctx
.db()
.map_err(|e| e.to_string())?
.list_workspaces()
.map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
println!("No workspaces found");
} else {
@@ -58,6 +61,7 @@ fn list(ctx: &CliContext) -> CommandResult {
fn show(ctx: &CliContext, workspace_id: &str) -> CommandResult {
let workspace = ctx
.db()
.map_err(|e| e.to_string())?
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace: {e}"))?;
let output = serde_json::to_string_pretty(&workspace)
@@ -85,6 +89,7 @@ fn create(
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_workspace(&workspace, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
@@ -98,6 +103,7 @@ fn create(
let workspace = Workspace { name, ..Default::default() };
let created = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_workspace(&workspace, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
@@ -110,12 +116,14 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let existing = ctx
.db()
.map_err(|e| e.to_string())?
.get_workspace(&id)
.map_err(|e| format!("Failed to get workspace for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?;
let saved = ctx
.db()
.map_err(|e| e.to_string())?
.upsert_workspace(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update workspace: {e}"))?;
@@ -131,6 +139,7 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
let deleted = ctx
.db()
.map_err(|e| e.to_string())?
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
println!("Deleted workspace: {}", deleted.id);
+4 -6
View File
@@ -51,11 +51,9 @@ impl CliContext {
};
// Guest: the desktop may have this DB open, so only what's safe beside a live session
let _ = yaak_lifecycle::on_launch(
&yaak_lifecycle::Host::guest(),
&query_manager.connect(),
&blob_manager,
);
if let Ok(db) = query_manager.connect() {
let _ = yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), &db, &blob_manager);
}
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
@@ -120,7 +118,7 @@ impl CliContext {
&self.data_dir
}
pub fn db(&self) -> ClientDb<'_> {
pub fn db(&self) -> yaak_models::error::Result<ClientDb<'_>> {
self.query_manager.connect()
}
+5 -3
View File
@@ -203,7 +203,7 @@ fn resolve_send_execution_context(
environment: Option<&str>,
explicit_cookie_jar_id: Option<&str>,
) -> Result<CliExecutionContext, String> {
if let Ok(request) = context.db().get_any_request(id) {
if let Ok(request) = context.db().map_err(|e| e.to_string())?.get_any_request(id) {
let (request_id, workspace_id) = match request {
AnyRequest::HttpRequest(r) => (Some(r.id), r.workspace_id),
AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id),
@@ -218,7 +218,7 @@ fn resolve_send_execution_context(
});
}
if let Ok(folder) = context.db().get_folder(id) {
if let Ok(folder) = context.db().map_err(|e| e.to_string())?.get_folder(id) {
let cookie_jar_id =
resolve_cookie_jar_id(context, &folder.workspace_id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
@@ -229,7 +229,7 @@ fn resolve_send_execution_context(
});
}
if let Ok(workspace) = context.db().get_workspace(id) {
if let Ok(workspace) = context.db().map_err(|e| e.to_string())?.get_workspace(id) {
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
request_id: None,
@@ -250,6 +250,7 @@ fn resolve_request_execution_context(
) -> Result<CliExecutionContext, String> {
let request = context
.db()
.map_err(|e| e.to_string())?
.get_any_request(request_id)
.map_err(|e| format!("Failed to get request: {e}"))?;
@@ -279,6 +280,7 @@ fn resolve_cookie_jar_id(
let default_cookie_jar = context
.db()
.map_err(|e| e.to_string())?
.list_cookie_jars(workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?
.into_iter()
+69 -52
View File
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
@@ -150,7 +150,11 @@ async fn build_plugin_reply(
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
}
HostRequest::ListOpenWorkspaces(_) => {
let workspaces = match host_context.query_manager.connect().list_workspaces() {
let workspaces = match host_context
.query_manager
.connect()
.and_then(|db| db.list_workspaces())
{
Ok(workspaces) => workspaces
.into_iter()
.map(|w| WorkspaceInfo { id: w.id.clone(), name: w.name, label: w.id })
@@ -189,7 +193,7 @@ async fn build_plugin_reply(
match host_context
.query_manager
.connect()
.list_cookie_jars(http_request.workspace_id.as_str())
.and_then(|db| db.list_cookie_jars(http_request.workspace_id.as_str()))
{
Ok(cookie_jars) => cookie_jars
.into_iter()
@@ -264,19 +268,20 @@ async fn build_plugin_reply(
..event.context.clone()
};
let environment_chain =
match host_context.query_manager.connect().resolve_environments(
let environment_chain = match host_context.query_manager.connect().and_then(|db| {
db.resolve_environments(
&grpc_request.workspace_id,
grpc_request.folder_id.as_deref(),
execution_context.environment_id.as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in CLI: {err}"),
}));
}
};
)
}) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in CLI: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
@@ -324,19 +329,20 @@ async fn build_plugin_reply(
..event.context.clone()
};
let environment_chain =
match host_context.query_manager.connect().resolve_environments(
let environment_chain = match host_context.query_manager.connect().and_then(|db| {
db.resolve_environments(
&http_request.workspace_id,
http_request.folder_id.as_deref(),
execution_context.environment_id.as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in CLI: {err}"),
}));
}
};
)
}) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in CLI: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
@@ -380,7 +386,11 @@ async fn build_plugin_reply(
};
let folder_id = execution_context.request_id.as_ref().and_then(|rid| {
match host_context.query_manager.connect().get_any_request(rid) {
match host_context
.query_manager
.connect()
.and_then(|db| db.get_any_request(rid))
{
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
@@ -388,19 +398,20 @@ async fn build_plugin_reply(
}
});
let environment_chain =
match host_context.query_manager.connect().resolve_environments(
let environment_chain = match host_context.query_manager.connect().and_then(|db| {
db.resolve_environments(
&workspace_id,
folder_id.as_deref(),
execution_context.environment_id.as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in CLI: {err}"),
}));
}
};
)
}) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in CLI: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
@@ -476,15 +487,18 @@ async fn build_plugin_reply(
));
};
let cookie_jar =
match host_context.query_manager.connect().get_cookie_jar(cookie_jar_id) {
Ok(cookie_jar) => cookie_jar,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in CLI: {err}"),
}));
}
};
let cookie_jar = match host_context
.query_manager
.connect()
.and_then(|db| db.get_cookie_jar(cookie_jar_id))
{
Ok(cookie_jar) => cookie_jar,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in CLI: {err}"),
}));
}
};
let names = cookie_jar.cookies.into_iter().map(|c| c.name).collect();
@@ -499,15 +513,18 @@ async fn build_plugin_reply(
));
};
let cookie_jar =
match host_context.query_manager.connect().get_cookie_jar(cookie_jar_id) {
Ok(cookie_jar) => cookie_jar,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in CLI: {err}"),
}));
}
};
let cookie_jar = match host_context
.query_manager
.connect()
.and_then(|db| db.get_cookie_jar(cookie_jar_id))
{
Ok(cookie_jar) => cookie_jar,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in CLI: {err}"),
}));
}
};
let value =
get_cookie_value_from_jar(cookie_jar.cookies, &req.name, req.domain.as_deref());
+5 -2
View File
@@ -9,8 +9,11 @@ pub fn resolve_workspace_id(
return Ok(workspace_id.to_string());
}
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
let workspaces = ctx
.db()
.map_err(|e| e.to_string())?
.list_workspaces()
.map_err(|e| format!("Failed to list workspaces: {e}"))?;
match workspaces.as_slice() {
[] => Err(format!("No workspaces found. {command_name} requires a workspace ID.")),
[workspace] => Ok(workspace.id.clone()),
+5
View File
@@ -41,6 +41,7 @@ pub fn seed_workspace(data_dir: &Path, workspace_id: &str) {
query_manager(data_dir)
.connect()
.unwrap()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.expect("Failed to seed workspace");
}
@@ -57,6 +58,7 @@ pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
query_manager(data_dir)
.connect()
.unwrap()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed request");
}
@@ -71,6 +73,7 @@ pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {
query_manager(data_dir)
.connect()
.unwrap()
.upsert_folder(&folder, &UpdateSource::Sync)
.expect("Failed to seed folder");
}
@@ -86,6 +89,7 @@ pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str)
query_manager(data_dir)
.connect()
.unwrap()
.upsert_grpc_request(&request, &UpdateSource::Sync)
.expect("Failed to seed gRPC request");
}
@@ -101,6 +105,7 @@ pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &
query_manager(data_dir)
.connect()
.unwrap()
.upsert_websocket_request(&request, &UpdateSource::Sync)
.expect("Failed to seed WebSocket request");
}
@@ -42,7 +42,7 @@ fn create_list_show_delete_round_trip() {
.success()
.stdout(contains(format!("Deleted environment: {environment_id}")));
assert!(query_manager(data_dir).connect().get_environment(&environment_id).is_err());
assert!(query_manager(data_dir).connect().unwrap().get_environment(&environment_id).is_err());
}
#[test]
+1 -1
View File
@@ -36,7 +36,7 @@ fn create_list_show_delete_round_trip() {
.success()
.stdout(contains(format!("Deleted folder: {folder_id}")));
assert!(query_manager(data_dir).connect().get_folder(&folder_id).is_err());
assert!(query_manager(data_dir).connect().unwrap().get_folder(&folder_id).is_err());
}
#[test]
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
use yaak_models::util::UpdateSource;
#[test]
fn export_writes_yaak_workspace_file() {
@@ -80,7 +81,7 @@ fn import_reads_yaak_workspace_file() {
.stdout(contains("Imported 1 workspace, 1 HTTP request"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
@@ -159,7 +160,7 @@ fn import_postman_environment_uses_workspace_id() {
.stdout(contains("Imported 1 environment"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let environments =
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
@@ -216,7 +217,7 @@ fn re_import_merges_into_linked_workspace() {
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
db.list_workspaces()
.expect("list workspaces")
.into_iter()
@@ -247,7 +248,7 @@ fn re_import_merges_into_linked_workspace() {
.stdout(contains("Skipped 1 removed from source"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let requests = db.list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
assert_eq!(
@@ -257,3 +258,60 @@ fn re_import_merges_into_linked_workspace() {
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
assert!(requests.iter().any(|r| r.name == "Request C"));
}
#[test]
fn re_import_leaves_deleted_resources_alone() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args(["import", import_path.to_str().expect("import path is utf-8")])
.assert()
.success();
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect().unwrap();
let workspace_id = db
.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id;
let request_b = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Request B")
.expect("request B imported");
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync)
.expect("delete request B");
workspace_id
};
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Skipped 1 ignored"));
let query_manager = query_manager(data_dir);
let requests =
query_manager.connect().unwrap().list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
assert_eq!(requests[0].name, "Request A");
}
@@ -43,7 +43,7 @@ fn show_and_delete_yes_round_trip() {
.success()
.stdout(contains(format!("Deleted request: {request_id}")));
assert!(query_manager(data_dir).connect().get_http_request(&request_id).is_err());
assert!(query_manager(data_dir).connect().unwrap().get_http_request(&request_id).is_err());
}
#[test]
@@ -61,7 +61,11 @@ fn delete_without_yes_fails_in_non_interactive_mode() {
.stderr(contains("Refusing to delete in non-interactive mode without --yes"));
assert!(
query_manager(data_dir).connect().get_http_request("rq_seed_delete_noninteractive").is_ok()
query_manager(data_dir)
.connect()
.unwrap()
.get_http_request("rq_seed_delete_noninteractive")
.is_ok()
);
}
@@ -122,6 +126,7 @@ fn create_allows_workspace_only_with_empty_defaults() {
let request = query_manager(data_dir)
.connect()
.unwrap()
.get_http_request(&request_id)
.expect("Failed to load created request");
assert_eq!(request.workspace_id, "wk_test");
@@ -207,7 +212,7 @@ fn request_send_persists_response_body_and_events() {
.stdout(contains("hello from integration test"));
let qm = query_manager(data_dir);
let db = qm.connect();
let db = qm.connect().unwrap();
let responses =
db.list_http_responses_for_request(&request_id, None).expect("Failed to load responses");
assert_eq!(responses.len(), 1, "expected exactly one persisted response");
@@ -26,6 +26,7 @@ fn top_level_send_folder_sends_http_requests_and_prints_summary() {
};
query_manager(data_dir)
.connect()
.unwrap()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed folder request");
@@ -26,7 +26,7 @@ fn create_show_delete_round_trip() {
.success()
.stdout(contains(format!("Deleted workspace: {workspace_id}")));
assert!(query_manager(data_dir).connect().get_workspace(&workspace_id).is_err());
assert!(query_manager(data_dir).connect().unwrap().get_workspace(&workspace_id).is_err());
}
#[test]
+5 -4
View File
@@ -27,12 +27,9 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
LAUNCH_INFO.get_or_init(|| {
let now = Utc::now().naive_utc();
let mut info = LaunchEventInfo {
version_since: app_handle.db().get_key_value_dte(NAMESPACE, VERSION_SINCE_KEY, now),
current_version: app_handle.package_info().version.to_string(),
user_since: app_handle.db().get_settings().created_at,
num_launches: app_handle.db().get_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, 0) + 1,
// The rest will be set below
// The rest is read and written inside the transaction below
..Default::default()
};
@@ -42,6 +39,10 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
app_handle
.with_tx(|tx| {
info.version_since = tx.get_key_value_dte(NAMESPACE, VERSION_SINCE_KEY, now);
info.user_since = tx.get_settings().created_at;
info.num_launches = tx.get_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, 0) + 1;
// Load the previously tracked version
let curr_db = tx.get_key_value_str(NAMESPACE, LAST_VERSION_KEY, "");
let prev_db = tx.get_key_value_str(NAMESPACE, PREV_VERSION_KEY, "");
+251 -215
View File
@@ -237,17 +237,17 @@ async fn cmd_grpc_reflect<R: Runtime>(
app_handle: AppHandle<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<Vec<ServiceDefinition>> {
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let unrendered_request = app_handle.db()?.get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) =
resolve_grpc_request(&window.db(), &unrendered_request)?;
resolve_grpc_request(&window.db()?, &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
let environment_chain = app_handle.db()?.resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
app_handle.db()?.resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -266,7 +266,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
let uri = safe_uri(&req.url);
let metadata = build_metadata(&window, &req, &auth_context_id).await?;
let settings = window.db().get_settings();
let settings = window.db()?.get_settings();
let client_certificate =
find_client_certificate(req.url.as_str(), &settings.client_certificates);
let proto_files: Vec<PathBuf> =
@@ -298,16 +298,16 @@ async fn cmd_grpc_go<R: Runtime>(
window: WebviewWindow<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<String> {
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let unrendered_request = app_handle.db()?.get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) =
resolve_grpc_request(&window.db(), &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
resolve_grpc_request(&window.db()?, &unrendered_request)?;
let environment_chain = app_handle.db()?.resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
app_handle.db()?.resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -327,10 +327,10 @@ async fn cmd_grpc_go<R: Runtime>(
let metadata = build_metadata(&window, &request, &auth_context_id).await?;
// Find matching client certificate for this URL
let settings = app_handle.db().get_settings();
let settings = app_handle.db()?.get_settings();
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
let conn = app_handle.db().upsert_grpc_connection(
let conn = app_handle.db()?.upsert_grpc_connection(
&GrpcConnection {
workspace_id: request.workspace_id.clone(),
request_id: request.id.clone(),
@@ -386,7 +386,7 @@ async fn cmd_grpc_go<R: Runtime>(
let connection = match connection {
Ok(c) => c,
Err(err) => {
app_handle.db().upsert_grpc_connection(
app_handle.db()?.upsert_grpc_connection(
&GrpcConnection {
elapsed: start.elapsed().as_millis() as i32,
error: Some(err.to_string()),
@@ -495,7 +495,7 @@ async fn cmd_grpc_go<R: Runtime>(
.await?;
let msg = strip_json_comments(&msg);
app_handle.db().upsert_grpc_event(
app_handle.db()?.upsert_grpc_event(
&GrpcEvent {
content: format!("Connecting to {}", req.url),
event_type: GrpcEventType::ConnectionStart,
@@ -513,24 +513,28 @@ async fn cmd_grpc_go<R: Runtime>(
let window_label = window.label().to_string();
move |result: std::result::Result<String, String>| match result {
Ok(msg) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
let _ = app_handle.db().and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
)
});
}
Err(error) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: format!("Failed to send message: {}", error),
event_type: GrpcEventType::Error,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
let _ = app_handle.db().and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: format!("Failed to send message: {}", error),
event_type: GrpcEventType::Error,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
)
});
}
}
};
@@ -584,14 +588,16 @@ async fn cmd_grpc_go<R: Runtime>(
if !method_desc.is_client_streaming() {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
event_type: GrpcEventType::ClientMessage,
content: msg,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
event_type: GrpcEventType::ClientMessage,
content: msg,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
@@ -599,20 +605,22 @@ async fn cmd_grpc_go<R: Runtime>(
Some(Ok(msg)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(msg.metadata().clone()),
content: if msg.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(msg.metadata().clone()),
content: if msg.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
let response_message = msg.into_inner();
let content = match connection
@@ -623,82 +631,92 @@ async fn cmd_grpc_go<R: Runtime>(
Err(err) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
return;
}
};
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
event_type: GrpcEventType::ConnectionEnd,
status: Some(Code::Ok as i32),
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
event_type: GrpcEventType::ConnectionEnd,
status: Some(Code::Ok as i32),
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
app_handle
.db()
.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
None => {
@@ -710,20 +728,22 @@ async fn cmd_grpc_go<R: Runtime>(
Some(Ok(stream)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(stream.metadata().clone()),
content: if stream.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(stream.metadata().clone()),
content: if stream.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
stream.into_inner()
}
@@ -731,42 +751,46 @@ async fn cmd_grpc_go<R: Runtime>(
warn!("GRPC stream error {e:?}");
app_handle
.db()
.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
return;
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
return;
}
@@ -784,30 +808,34 @@ async fn cmd_grpc_go<R: Runtime>(
Err(err) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
break;
}
};
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: message,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: message,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
Ok(None) => {
@@ -815,33 +843,37 @@ async fn cmd_grpc_go<R: Runtime>(
stream.trailers().await.unwrap_or_default().unwrap_or_default();
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
status: Some(Code::Ok as i32),
metadata: metadata_to_map(trailers),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
status: Some(Code::Ok as i32),
metadata: metadata_to_map(trailers),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
break;
}
Err(status) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.and_then(|db| {
db.upsert_grpc_event(
&GrpcEvent {
content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
break;
}
@@ -856,7 +888,7 @@ async fn cmd_grpc_go<R: Runtime>(
let w = app_handle.clone();
tokio::select! {
_ = grpc_listen => {
let events = w.db().list_grpc_events(&conn_id).unwrap();
let events = w.db().and_then(|db| db.list_grpc_events(&conn_id)).unwrap();
let closed_event = events
.iter()
.find(|e| GrpcEventType::ConnectionEnd == e.event_type);
@@ -874,7 +906,7 @@ async fn cmd_grpc_go<R: Runtime>(
}).unwrap();
},
_ = cancelled_rx.changed() => {
w.db().upsert_grpc_event(
w.db().and_then(|db| db.upsert_grpc_event(
&GrpcEvent {
content: "Cancelled".to_string(),
event_type: GrpcEventType::ConnectionEnd,
@@ -882,7 +914,7 @@ async fn cmd_grpc_go<R: Runtime>(
..base_msg.clone()
},
&UpdateSource::from_window_label(window.label()),
).unwrap();
)).unwrap();
w.with_tx(|c| {
c.upsert_grpc_connection(
&GrpcConnection{
@@ -923,11 +955,11 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
let response = HttpResponse::default();
request.id = "".to_string();
let environment = match environment_id {
Some(id) => Some(app_handle.db().get_environment(id)?),
Some(id) => Some(app_handle.db()?.get_environment(id)?),
None => None,
};
let cookie_jar = match cookie_jar_id {
Some(id) => Some(app_handle.db().get_cookie_jar(id)?),
Some(id) => Some(app_handle.db()?.get_cookie_jar(id)?),
None => None,
};
@@ -964,7 +996,7 @@ async fn cmd_http_response_body<R: Runtime>(
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let location = locate_response_body(&window.db(), response_id)?;
let location = locate_response_body(&window.db()?, response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
@@ -987,7 +1019,7 @@ async fn cmd_get_sse_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
let Some(body_path) = locate_response_body(&app_handle.db()?, response_id)?.path else {
return Ok(Vec::new());
};
@@ -1077,10 +1109,10 @@ async fn cmd_send_http_request<R: Runtime>(
cookie_jar_id: Option<&str>,
request_id: String,
) -> YaakResult<HttpResponse> {
let request = app_handle.db().get_http_request(&request_id)?;
let request = app_handle.db()?.get_http_request(&request_id)?;
let blobs = app_handle.blob_manager();
let response = app_handle.db().upsert_http_response(
let response = app_handle.db()?.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: request.workspace_id.clone(),
@@ -1098,7 +1130,7 @@ async fn cmd_send_http_request<R: Runtime>(
});
let environment = match environment_id {
Some(id) => match app_handle.db().get_environment(id) {
Some(id) => match app_handle.db()?.get_environment(id) {
Ok(env) => Some(env),
Err(e) => {
warn!("Failed to find environment by id {id} {}", e);
@@ -1109,7 +1141,7 @@ async fn cmd_send_http_request<R: Runtime>(
};
let cookie_jar = match cookie_jar_id {
Some(id) => Some(app_handle.db().get_cookie_jar(id)?),
Some(id) => Some(app_handle.db()?.get_cookie_jar(id)?),
None => None,
};
@@ -1125,8 +1157,8 @@ async fn cmd_send_http_request<R: Runtime>(
{
Ok(sent) => sent.response,
Err(e) => {
let resp = app_handle.db().get_http_response(&response.id)?;
app_handle.db().upsert_http_response(
let resp = app_handle.db()?.get_http_response(&response.id)?;
app_handle.db()?.upsert_http_response(
&HttpResponse {
state: HttpResponseState::Closed,
error: Some(e.to_string()),
@@ -1149,7 +1181,7 @@ async fn cmd_new_child_window<R: Runtime>(
title: &str,
inner_size: (f64, f64),
) -> YaakResult<()> {
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
let use_native_titlebar = parent_window.app_handle().db()?.get_settings().use_native_titlebar;
let initialization_script = initial_appearance_script(&parent_window.app_handle());
let win = yaak_window::window::create_child_window(
&parent_window,
@@ -1165,7 +1197,7 @@ async fn cmd_new_child_window<R: Runtime>(
}
async fn cmd_new_main_window<R: Runtime>(app_handle: AppHandle<R>, url: &str) -> YaakResult<()> {
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
let use_native_titlebar = app_handle.db()?.get_settings().use_native_titlebar;
let initialization_script = initial_appearance_script(&app_handle);
let win = yaak_window::window::create_main_window(
&app_handle,
@@ -1182,7 +1214,7 @@ async fn cmd_check_for_updates<R: Runtime>(
yaak_updater: State<'_, Mutex<YaakUpdater>>,
) -> YaakResult<bool> {
let update_mode = get_update_mode(&window).await?;
let settings = window.db().get_settings();
let settings = window.db()?.get_settings();
Ok(yaak_updater
.lock()
.await
@@ -1270,7 +1302,7 @@ pub fn run() {
let lifecycle_host = yaak_lifecycle::Host::owner()
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
if let Err(e) =
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
yaak_lifecycle::on_launch(&lifecycle_host, &app.db()?, &app.blob_manager())
{
error!("on_launch hook failed: {e:?}");
}
@@ -1346,7 +1378,7 @@ pub fn run() {
app.manage(Mutex::new(ws_manager));
// Specific settings
let settings = app.db().get_settings();
let settings = app.db()?.get_settings();
app.app_handle().set_native_titlebar(settings.use_native_titlebar);
monitor_plugin_events(&app.app_handle().clone());
@@ -1359,7 +1391,8 @@ pub fn run() {
.run(|app_handle, event| {
match event {
RunEvent::Ready => {
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
let settings = app_handle.db().map(|db| db.get_settings()).unwrap_or_default();
let use_native_titlebar = settings.use_native_titlebar;
let initialization_script = initial_appearance_script(app_handle);
if let Ok(win) = yaak_window::window::create_main_window(
app_handle,
@@ -1398,7 +1431,10 @@ pub fn run() {
let w = app_handle.get_webview_window(&label).unwrap();
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
let settings = w.db().get_settings();
let Ok(settings) = w.db().map(|db| db.get_settings()) else {
warn!("Failed to read settings for update check");
return;
};
if settings.autoupdate {
time::sleep(Duration::from_secs(3)).await; // Wait a bit so it's not so jarring
let val: State<'_, Mutex<YaakUpdater>> = h.state();
@@ -1434,7 +1470,7 @@ pub fn run() {
}
async fn get_update_mode<R: Runtime>(window: &WebviewWindow<R>) -> YaakResult<UpdateMode> {
let settings = window.db().get_settings();
let settings = window.db()?.get_settings();
Ok(UpdateMode::new(settings.update_channel.as_str()))
}
@@ -1561,13 +1597,13 @@ fn get_window_from_plugin_context<R: Runtime>(
}
fn workspace_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<Workspace> {
window.workspace_id().and_then(|id| window.db().get_workspace(&id).ok())
window.workspace_id().and_then(|id| window.db().and_then(|db| db.get_workspace(&id)).ok())
}
fn environment_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<Environment> {
window.environment_id().and_then(|id| window.db().get_environment(&id).ok())
window.environment_id().and_then(|id| window.db().and_then(|db| db.get_environment(&id)).ok())
}
fn cookie_jar_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<CookieJar> {
window.cookie_jar_id().and_then(|id| window.db().get_cookie_jar(&id).ok())
window.cookie_jar_id().and_then(|id| window.db().and_then(|db| db.get_cookie_jar(&id)).ok())
}
@@ -37,11 +37,9 @@ fn drain_model_changes_batch<R: Runtime>(
app_handle: &tauri::AppHandle<R>,
cursor: &mut ModelChangeCursor,
) -> bool {
let changes = match query_manager.connect().list_model_changes_since(
&cursor.created_at,
cursor.id,
MODEL_CHANGES_POLL_BATCH_SIZE,
) {
let changes = match query_manager.connect().and_then(|db| {
db.list_model_changes_since(&cursor.created_at, cursor.id, MODEL_CHANGES_POLL_BATCH_SIZE)
}) {
Ok(changes) => changes,
Err(err) => {
error!("Failed to poll model_changes rows: {err:?}");
@@ -92,7 +90,7 @@ async fn run_model_change_poller<R: Runtime>(
/// Extension trait for accessing the QueryManager from Tauri Manager types.
pub trait QueryManagerExt<'a, R> {
fn db_manager(&'a self) -> State<'a, QueryManager>;
fn db(&'a self) -> ClientDb<'a>;
fn db(&'a self) -> Result<ClientDb<'a>>;
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
where
F: FnOnce(&ClientDb) -> Result<T>;
@@ -103,7 +101,7 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
self.state::<QueryManager>()
}
fn db(&'a self) -> ClientDb<'a> {
fn db(&'a self) -> Result<ClientDb<'a>> {
let qm = self.state::<QueryManager>();
qm.inner().connect()
}
@@ -55,7 +55,7 @@ impl YaakNotifier {
seen.push(id.to_string());
debug!("Marked notification as seen {}", id);
let seen_json = serde_json::to_string(&seen)?;
window.db().set_key_value_raw(
window.db()?.set_key_value_raw(
KV_NAMESPACE,
KV_KEY,
seen_json.as_str(),
@@ -74,7 +74,7 @@ impl YaakNotifier {
self.last_check = Some(Instant::now());
if !app_handle.db().get_settings().check_notifications {
if !app_handle.db()?.get_settings().check_notifications {
info!("Notifications are disabled. Skipping check.");
return Ok(());
}
@@ -139,7 +139,7 @@ impl YaakNotifier {
}
async fn get_kv<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<String>> {
match app_handle.db().get_key_value_raw("notifications", "seen") {
match app_handle.db()?.get_key_value_raw("notifications", "seen") {
None => Ok(Vec::new()),
Some(v) => Ok(serde_json::from_str(&v.value)?),
}
@@ -155,7 +155,7 @@ fn get_updater_status<R: Runtime>(app_handle: &AppHandle<R>) -> &'static str {
#[cfg(all(feature = "updater", target_os = "linux"))]
{
let settings = app_handle.db().get_settings();
let settings = app_handle.db()?.get_settings();
if !settings.autoupdate {
// Updates are explicitly disabled
"disabled"
@@ -170,7 +170,7 @@ fn get_updater_status<R: Runtime>(app_handle: &AppHandle<R>) -> &'static str {
#[cfg(all(feature = "updater", not(target_os = "linux")))]
{
let settings = app_handle.db().get_settings();
let settings = app_handle.db()?.get_settings();
if settings.autoupdate { "enabled" } else { "disabled" }
}
}
@@ -104,14 +104,14 @@ async fn handle_host_plugin_request<R: Runtime>(
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
}
HostRequest::ReloadResponse(req) => {
let plugins = app_handle.db().list_plugins()?;
let plugins = app_handle.db()?.list_plugins()?;
for plugin in plugins {
if plugin.directory != plugin_handle.dir {
continue;
}
let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin };
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
app_handle.db()?.upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
}
if !req.silent {
@@ -199,7 +199,7 @@ async fn handle_host_plugin_request<R: Runtime>(
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment_id = environment_from_window(&window).map(|e| e.id);
let environment_chain = window.db().resolve_environments(
let environment_chain = window.db()?.resolve_environments(
&workspace.id,
req.grpc_request.folder_id.as_deref(),
environment_id.as_deref(),
@@ -225,7 +225,7 @@ async fn handle_host_plugin_request<R: Runtime>(
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment_id = environment_from_window(&window).map(|e| e.id);
let environment_chain = window.db().resolve_environments(
let environment_chain = window.db()?.resolve_environments(
&workspace.id,
req.http_request.folder_id.as_deref(),
environment_id.as_deref(),
@@ -252,7 +252,7 @@ async fn handle_host_plugin_request<R: Runtime>(
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment_id = environment_from_window(&window).map(|e| e.id);
let folder_id = if let Some(id) = window.request_id() {
match window.db().get_any_request(&id) {
match window.db()?.get_any_request(&id) {
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
@@ -261,7 +261,7 @@ async fn handle_host_plugin_request<R: Runtime>(
} else {
None
};
let environment_chain = window.db().resolve_environments(
let environment_chain = window.db()?.resolve_environments(
&workspace.id,
folder_id.as_deref(),
environment_id.as_deref(),
@@ -294,7 +294,7 @@ async fn handle_host_plugin_request<R: Runtime>(
HttpResponse::default()
} else {
let blobs = window.blob_manager();
window.db().upsert_http_response(
window.db()?.upsert_http_response(
&HttpResponse {
request_id: http_request.id.clone(),
workspace_id: http_request.workspace_id.clone(),
@@ -328,7 +328,7 @@ async fn handle_host_plugin_request<R: Runtime>(
HostRequest::OpenWindow(req) => {
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
let (close_tx, mut close_rx) = tokio::sync::mpsc::channel(128);
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
let use_native_titlebar = app_handle.db()?.get_settings().use_native_titlebar;
let win_config = CreateWindowConfig {
url: &req.url,
label: &req.label,
@@ -438,7 +438,7 @@ async fn handle_host_plugin_request<R: Runtime>(
let environment_id = environment_from_window(&w).map(|m| m.id);
let workspace_id = workspace_from_window(&w).map(|m| m.id);
let request_id =
match app_handle.db().get_any_request(&w.request_id().unwrap_or_default()) {
match app_handle.db()?.get_any_request(&w.request_id().unwrap_or_default()) {
Ok(AnyRequest::HttpRequest(r)) => Some(r.id),
Ok(AnyRequest::WebsocketRequest(r)) => Some(r.id),
Ok(AnyRequest::GrpcRequest(r)) => Some(r.id),
@@ -107,7 +107,7 @@ impl PluginUpdater {
let app_version = window.app_handle().package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let plugins = window.app_handle().db().list_plugins()?;
let plugins = window.app_handle().db()?.list_plugins()?;
let updates = check_plugin_updates(&http_client, plugins.clone()).await?;
if updates.plugins.is_empty() {
@@ -203,7 +203,7 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
// Resolve the manager before writing the row so startup's plugin snapshot
// can't include it and boot it a second time
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let plugin = window.db().upsert_plugin(
let plugin = window.db()?.upsert_plugin(
&Plugin {
directory: directory.into(),
url: None,
@@ -234,7 +234,7 @@ pub async fn cmd_plugins_updates<R: Runtime>(
) -> Result<PluginUpdatesResponse> {
let app_version = app_handle.package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let plugins = app_handle.db().list_plugins()?;
let plugins = app_handle.db()?.list_plugins()?;
Ok(check_plugin_updates(&http_client, plugins).await?)
}
@@ -243,7 +243,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
) -> Result<Vec<PluginNameVersion>> {
let app_version = window.app_handle().package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let plugins = window.db().list_plugins()?;
let plugins = window.db()?.list_plugins()?;
// Get list of available updates (already filtered to only registry plugins)
let updates = check_plugin_updates(&http_client, plugins).await?;
+11 -11
View File
@@ -42,26 +42,26 @@ use yaak_models::models::{
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
GetWorkspaceActionsResponse,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
RenderPurpose,
};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc::RpcRouter;
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_sync::sync::SyncOp;
use yaak_templates::TemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::TemplateCallback;
use yaak_ws::WebsocketManager;
/// Per-call context: the window a command was invoked from.
@@ -467,7 +467,7 @@ async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportRe
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
Ok(ctx.window.db()?.list_import_sources(&req.workspace_id)?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
@@ -480,7 +480,7 @@ async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdIm
},
(None, None) => return Ok(Vec::new()),
};
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
Ok(ctx.window.db()?.list_import_sources_by_origin(&origin)?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
+4 -3
View File
@@ -26,9 +26,10 @@ pub(crate) async fn cmd_sync_calculate<R: Runtime>(
return Err(InvalidSyncDirectory(sync_dir.to_string_lossy().to_string()).into());
}
let db = app_handle.db();
let version = app_handle.package_info().version.to_string();
let db_candidates = get_db_candidates(&db, &version, workspace_id, sync_dir)?;
// Scoped so the pooled connection is back before the directory walk below, which is
// as slow as the sync directory is large.
let db_candidates = get_db_candidates(&app_handle.db()?, &version, workspace_id, sync_dir)?;
let fs_candidates = get_fs_candidates(sync_dir)?
.into_iter()
// Only keep items in the same workspace
@@ -49,7 +50,7 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
sync_dir: &Path,
workspace_id: &str,
) -> Result<()> {
let db = app_handle.db();
let db = app_handle.db()?;
let blobs = app_handle.blob_manager();
let sync_state_ops = apply_sync_ops(&db, &blobs, workspace_id, sync_dir, sync_ops)?;
apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;
+1 -1
View File
@@ -75,7 +75,7 @@ impl YaakUpdater {
auto_download: bool,
update_trigger: UpdateTrigger,
) -> Result<bool> {
let settings = window.db().get_settings();
let settings = window.db()?.get_settings();
let update_key = format!("{:x}", md5::compute(settings.id));
self.last_check = Some(Instant::now());
+38 -32
View File
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
use url::Url;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::CookieStore;
use yaak_http::path_placeholders::apply_path_placeholders;
@@ -26,7 +27,6 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_ws::{WebsocketManager, render_websocket_request};
pub async fn cmd_ws_send<R: Runtime>(
@@ -36,14 +36,14 @@ pub async fn cmd_ws_send<R: Runtime>(
window: WebviewWindow<R>,
ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> {
let connection = app_handle.db().get_websocket_connection(connection_id)?;
let connection = app_handle.db()?.get_websocket_connection(connection_id)?;
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
.await
{
Ok(connection) => Ok(connection),
Err(e) => {
app_handle.db().upsert_websocket_event(
app_handle.db()?.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
@@ -68,14 +68,14 @@ async fn send_websocket_message<R: Runtime>(
window: &WebviewWindow<R>,
ws_manager: &Mutex<WebsocketManager>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
let environment_chain = app_handle.db().resolve_environments(
let unrendered_request = app_handle.db()?.get_websocket_request(&connection.request_id)?;
let environment_chain = app_handle.db()?.resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
resolve_websocket_request(&window.db()?, &unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
@@ -96,7 +96,7 @@ async fn send_websocket_message<R: Runtime>(
let mut ws_manager = ws_manager.lock().await;
ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?;
app_handle.db().upsert_websocket_event(
app_handle.db()?.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: request.id.clone(),
@@ -119,7 +119,7 @@ pub async fn cmd_ws_close<R: Runtime>(
ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> {
let connection = {
let db = app_handle.db();
let db = app_handle.db()?;
let connection = db.get_websocket_connection(connection_id)?;
db.upsert_websocket_connection(
&WebsocketConnection { state: WebsocketConnectionState::Closing, ..connection },
@@ -143,17 +143,17 @@ pub async fn cmd_ws_connect<R: Runtime>(
window: WebviewWindow<R>,
ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
let environment_chain = app_handle.db().resolve_environments(
let unrendered_request = app_handle.db()?.get_websocket_request(request_id)?;
let environment_chain = app_handle.db()?.resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let resolved_settings =
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
let settings = app_handle.db().get_settings();
app_handle.db()?.resolve_settings_for_websocket_request(&unrendered_request)?;
let settings = app_handle.db()?.get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
resolve_websocket_request(&window.db()?, &unrendered_request)?;
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
@@ -169,7 +169,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
)
.await?;
let connection = app_handle.db().upsert_websocket_connection(
let connection = app_handle.db()?.upsert_websocket_connection(
&WebsocketConnection {
workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(),
@@ -187,7 +187,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
let mut url = match Url::parse(&url) {
Ok(url) => url,
Err(e) => {
return Ok(app_handle.db().upsert_websocket_connection(
return Ok(app_handle.db()?.upsert_websocket_connection(
&WebsocketConnection {
error: Some(format!("Failed to parse URL {}", e.to_string())),
state: WebsocketConnectionState::Closed,
@@ -268,7 +268,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
resolved_settings.send_cookies.value || resolved_settings.store_cookies.value,
cookie_jar_id,
) {
(true, Some(id)) => Some(app_handle.db().get_cookie_jar(id)?),
(true, Some(id)) => Some(app_handle.db()?.get_cookie_jar(id)?),
_ => None,
};
let cookie_store =
@@ -321,7 +321,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
{
Ok(r) => r,
Err(e) => {
return Ok(app_handle.db().upsert_websocket_connection(
return Ok(app_handle.db()?.upsert_websocket_connection(
&WebsocketConnection {
error: Some(e.to_string()),
state: WebsocketConnectionState::Closed,
@@ -332,7 +332,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
}
};
app_handle.db().upsert_websocket_event(
app_handle.db()?.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: request.id.clone(),
@@ -366,11 +366,11 @@ pub async fn cmd_ws_connect<R: Runtime>(
if !set_cookie_headers.is_empty() {
store.store_cookies_from_response(&convert_ws_url_to_http(&url), &set_cookie_headers);
cookie_jar.cookies = store.get_all_cookies();
app_handle.db().upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?;
app_handle.db()?.upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?;
}
}
let connection = app_handle.db().upsert_websocket_connection(
let connection = app_handle.db()?.upsert_websocket_connection(
&WebsocketConnection {
state: WebsocketConnectionState::Connected,
headers: response_headers,
@@ -394,9 +394,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
has_written_close = true;
}
app_handle
.db()
.upsert_websocket_event(
let event = || {
app_handle.db()?.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection_id.clone(),
request_id: request_id.clone(),
@@ -416,13 +415,15 @@ pub async fn cmd_ws_connect<R: Runtime>(
},
&UpdateSource::from_window_label(&window_label),
)
.unwrap();
};
if let Err(e) = event() {
warn!("Failed to store WebSocket event: {e}");
}
}
info!("Websocket connection closed");
if !has_written_close {
app_handle
.db()
.upsert_websocket_event(
let close_event = app_handle.db().and_then(|db| {
db.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection_id.clone(),
request_id: request_id.clone(),
@@ -433,11 +434,13 @@ pub async fn cmd_ws_connect<R: Runtime>(
},
&UpdateSource::from_window_label(&window_label),
)
.unwrap();
});
if let Err(e) = close_event {
warn!("Failed to store WebSocket close event: {e}");
}
}
app_handle
.db()
.upsert_websocket_connection(
let closed = app_handle.db().and_then(|db| {
db.upsert_websocket_connection(
&WebsocketConnection {
workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(),
@@ -446,7 +449,10 @@ pub async fn cmd_ws_connect<R: Runtime>(
},
&UpdateSource::from_window_label(&window_label),
)
.unwrap();
});
if let Err(e) = closed {
warn!("Failed to mark WebSocket connection closed: {e}");
}
});
}
+9 -6
View File
@@ -16,11 +16,11 @@ use yaak_models::util::UpdateSource;
/// Extension trait for accessing the QueryManager from Tauri Manager types.
/// This is needed temporarily until all crates are refactored to not use Tauri.
trait QueryManagerExt<'a, R> {
fn db(&'a self) -> ClientDb<'a>;
fn db(&'a self) -> yaak_models::error::Result<ClientDb<'a>>;
}
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
fn db(&'a self) -> ClientDb<'a> {
fn db(&'a self) -> yaak_models::error::Result<ClientDb<'a>> {
let qm = self.state::<QueryManager>();
qm.inner().connect()
}
@@ -137,7 +137,7 @@ pub async fn activate_license<R: Runtime>(
}
let body: ActivateLicenseResponsePayload = response.json().await?;
window.app_handle().db().set_key_value_str(
window.app_handle().db()?.set_key_value_str(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
body.activation_id.as_str(),
@@ -172,7 +172,7 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
return Err(ServerError);
}
app_handle.db().delete_key_value(
app_handle.db()?.delete_key_value(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
&UpdateSource::from_window_label(window.label()),
@@ -191,7 +191,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
CheckActivationRequestPayload { app_platform: get_os_str().to_string(), app_version };
let activation_id = get_activation_id(window.app_handle()).await;
let settings = window.db().get_settings();
let settings = window.db()?.get_settings();
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS)).and_utc();
let has_activation_id = !activation_id.is_empty();
@@ -238,5 +238,8 @@ fn build_url(path: &str) -> String {
}
pub async fn get_activation_id<R: Runtime>(app_handle: &AppHandle<R>) -> String {
app_handle.db().get_key_value_str(KV_ACTIVATION_ID_KEY, KV_NAMESPACE, "")
app_handle
.db()
.map(|db| db.get_key_value_str(KV_ACTIVATION_ID_KEY, KV_NAMESPACE, ""))
.unwrap_or_default()
}
+1 -1
View File
@@ -12,7 +12,7 @@ pub use connection_or_tx::ConnectionOrTx;
pub use db_context::DbContext;
pub use error::{Error, Result};
pub use migrate::run_migrations;
pub use pool::{PoolError, SqliteConn, SqlitePool};
pub use pool::{PoolError, PoolStatus, SqliteConn, SqlitePool, status as pool_status};
pub use traits::{UpsertModelInfo, upsert_date};
pub use update_source::{ModelChangeEvent, UpdateSource};
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
+27
View File
@@ -23,17 +23,39 @@
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
//! connection blocks on `busy_timeout` and then fails).
/// What a pool looks like from outside at one moment. Enough to tell "everything is
/// checked out" apart from "nothing could be opened", which are unrelated problems that
/// both surface as an acquire timeout.
#[derive(Debug, Clone, Copy)]
pub struct PoolStatus {
pub connections: u32,
pub idle: u32,
}
impl std::fmt::Display for PoolStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} open, {} idle", self.connections, self.idle)
}
}
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::PoolStatus;
use r2d2_sqlite::SqliteConnectionManager;
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
pub type PoolError = r2d2::Error;
pub fn status(pool: &SqlitePool) -> PoolStatus {
let state = pool.state();
PoolStatus { connections: state.connections, idle: state.idle_connections }
}
}
#[cfg(target_arch = "wasm32")]
mod imp {
use super::PoolStatus;
use rusqlite::Connection;
use std::ops::Deref;
use std::rc::Rc;
@@ -66,6 +88,11 @@ mod imp {
#[derive(Debug, thiserror::Error)]
pub enum PoolError {}
/// The one connection, which is never checked out and never unavailable.
pub fn status(_pool: &SqlitePool) -> PoolStatus {
PoolStatus { connections: 1, idle: 1 }
}
#[derive(Debug)]
pub struct SqliteConn(Rc<Connection>);
-11
View File
@@ -331,17 +331,6 @@ export type ImportSource = {
lastImportedAt: string;
};
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+82 -23
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string, label: string, };
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string;
label: string;
};
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string },
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
export type ImportPlan = {
importer: string;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/**
* The model types an import plan can contain.
*/
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
+6 -6
View File
@@ -63,7 +63,7 @@ pub async fn cmd_call_http_request_action<H: PluginHost>(
req: CmdCallHttpRequestActionReq,
) -> Result<()> {
let inner = req.req;
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
let http_request = resolve_http_request(&host.db()?, &inner.args.http_request)?.0;
host.call_http_request_action(CallHttpRequestActionRequest {
args: CallHttpRequestActionArgs { http_request },
..inner
@@ -76,7 +76,7 @@ pub async fn cmd_call_grpc_request_action<H: PluginHost>(
req: CmdCallGrpcRequestActionReq,
) -> Result<()> {
let inner = req.req;
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
let grpc_request = resolve_grpc_request(&host.db()?, &inner.args.grpc_request)?.0;
host.call_grpc_request_action(CallGrpcRequestActionRequest {
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
..inner
@@ -89,7 +89,7 @@ pub async fn cmd_call_websocket_request_action<H: PluginHost>(
req: CmdCallWebsocketRequestActionReq,
) -> Result<()> {
let inner = req.req;
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
let websocket_request = host.db()?.get_websocket_request(&inner.args.websocket_request.id)?;
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
args: CallWebsocketRequestActionArgs { websocket_request },
..inner
@@ -102,7 +102,7 @@ pub async fn cmd_call_workspace_action<H: PluginHost>(
req: CmdCallWorkspaceActionReq,
) -> Result<()> {
let inner = req.req;
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
let workspace = host.db()?.get_workspace(&inner.args.workspace.id)?;
host.call_workspace_action(CallWorkspaceActionRequest {
args: CallWorkspaceActionArgs { workspace },
..inner
@@ -115,7 +115,7 @@ pub async fn cmd_call_folder_action<H: PluginHost>(
req: CmdCallFolderActionReq,
) -> Result<()> {
let inner = req.req;
let folder = host.db().get_folder(&inner.args.folder.id)?;
let folder = host.db()?.get_folder(&inner.args.folder.id)?;
host.call_folder_action(CallFolderActionRequest {
args: CallFolderActionArgs { folder },
..inner
@@ -152,6 +152,6 @@ pub async fn cmd_reload_plugins<H: PluginHost>(
host: H,
_req: CmdReloadPluginsReq,
) -> Result<Vec<(String, String)>> {
let plugins = host.db().list_plugins()?;
let plugins = host.db()?.list_plugins()?;
Ok(host.reload_plugins(plugins).await)
}
+2 -2
View File
@@ -68,11 +68,11 @@ pub trait Host: Clone {
PluginContext::new(Some(self.client_id().to_string()), self.session().workspace_id)
}
fn db(&self) -> ClientDb<'_> {
fn db(&self) -> yaak_models::error::Result<ClientDb<'_>> {
self.query_manager().connect()
}
fn blobs(&self) -> BlobContext {
fn blobs(&self) -> yaak_models::error::Result<BlobContext> {
self.blob_manager().connect()
}
}
+15 -13
View File
@@ -11,7 +11,7 @@ use yaak_models::queries::workspaces::default_headers;
use yaak_rpc_schema::*;
pub async fn models_upsert<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
let db = host.db();
let db = host.db()?;
let blobs = host.blob_manager();
let source = host.update_source();
Ok(yaak_models::models_ops::upsert_model(&db, blobs, req.model, &source)?)
@@ -49,25 +49,25 @@ pub async fn models_websocket_events<H: Host>(
host: H,
req: ModelsWebsocketEventsReq,
) -> Result<Vec<WebsocketEvent>> {
Ok(host.db().list_websocket_events(&req.connection_id)?)
Ok(host.db()?.list_websocket_events(&req.connection_id)?)
}
pub async fn models_grpc_events<H: Host>(
host: H,
req: ModelsGrpcEventsReq,
) -> Result<Vec<GrpcEvent>> {
Ok(host.db().list_grpc_events(&req.connection_id)?)
Ok(host.db()?.list_grpc_events(&req.connection_id)?)
}
pub async fn models_get_settings<H: Host>(host: H, _req: ModelsGetSettingsReq) -> Result<Settings> {
Ok(host.db().get_settings())
Ok(host.db()?.get_settings())
}
pub async fn models_get_graphql_introspection<H: Host>(
host: H,
req: ModelsGetGraphqlIntrospectionReq,
) -> Result<Option<GraphQlIntrospection>> {
Ok(host.db().get_graphql_introspection(&req.request_id))
Ok(host.db()?.get_graphql_introspection(&req.request_id))
}
pub async fn models_upsert_graphql_introspection<H: Host>(
@@ -75,7 +75,7 @@ pub async fn models_upsert_graphql_introspection<H: Host>(
req: ModelsUpsertGraphqlIntrospectionReq,
) -> Result<GraphQlIntrospection> {
let source = host.update_source();
Ok(host.db().upsert_graphql_introspection(
Ok(host.db()?.upsert_graphql_introspection(
&req.workspace_id,
&req.request_id,
req.content,
@@ -96,14 +96,14 @@ pub async fn models_workspace_models<H: PluginHost>(
// Add the global models
{
let db = host.db();
let db = host.db()?;
l.push(db.get_settings().into());
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
}
let plugins = {
let db = host.db();
let db = host.db()?;
db.list_plugins()?
};
@@ -112,7 +112,7 @@ pub async fn models_workspace_models<H: PluginHost>(
// Add the workspace children
if let Some(wid) = req.workspace_id.as_deref() {
let db = host.db();
let db = host.db()?;
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
@@ -132,7 +132,7 @@ pub async fn cmd_get_workspace_meta<H: Host>(
host: H,
req: CmdGetWorkspaceMetaReq,
) -> Result<WorkspaceMeta> {
let db = host.db();
let db = host.db()?;
let workspace = db.get_workspace(&req.workspace_id)?;
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
}
@@ -141,14 +141,16 @@ pub async fn cmd_delete_all_grpc_connections<H: Host>(
host: H,
req: CmdDeleteAllGrpcConnectionsReq,
) -> Result<()> {
Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?)
Ok(host
.db()?
.delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?)
}
pub async fn cmd_delete_all_http_responses<H: Host>(
host: H,
req: CmdDeleteAllHttpResponsesReq,
) -> Result<()> {
host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?;
host.db()?.delete_all_http_responses_for_request(&req.request_id, &host.update_source())?;
Ok(())
}
@@ -157,7 +159,7 @@ pub async fn cmd_ws_delete_connections<H: Host>(
req: CmdWsDeleteConnectionsReq,
) -> Result<()> {
Ok(host
.db()
.db()?
.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?)
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub async fn cmd_plugin_info<H: PluginHost>(
host: H,
req: CmdPluginInfoReq,
) -> Result<PluginMetadata> {
let plugin = host.db().get_plugin(&req.id)?;
let plugin = host.db()?.get_plugin(&req.id)?;
if let Some(metadata) = host.loaded_plugin_metadata(&plugin.directory).await {
return Ok(metadata);
}
+1 -1
View File
@@ -64,7 +64,7 @@ pub(crate) async fn render_form_values<H: PluginHost>(
};
let environment_chain =
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
host.db()?.resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
let cb = host.template_callback(purpose).await?;
let rendered =
+4 -4
View File
@@ -41,7 +41,7 @@ pub async fn cmd_get_http_response_events<H: Host>(
host: H,
req: CmdGetHttpResponseEventsReq,
) -> Result<Vec<HttpResponseEvent>> {
let events: Vec<HttpResponseEvent> = host.db().list_http_response_events(&req.response_id)?;
let events: Vec<HttpResponseEvent> = host.db()?.list_http_response_events(&req.response_id)?;
Ok(events)
}
@@ -55,7 +55,7 @@ pub async fn cmd_http_response_body_path<H: Host>(
host: H,
req: CmdHttpResponseBodyPathReq,
) -> Result<Option<String>> {
let location = locate_response_body(&host.db(), &req.response_id)?;
let location = locate_response_body(&host.db()?, &req.response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
@@ -64,7 +64,7 @@ pub async fn cmd_http_request_body<H: Host>(
req: CmdHttpRequestBodyReq,
) -> Result<Option<Vec<u8>>> {
let body_id = format!("{}.request", req.response_id);
let chunks = host.blobs().get_chunks(&body_id)?;
let chunks = host.blobs()?.get_chunks(&body_id)?;
if chunks.is_empty() {
return Ok(None);
@@ -76,7 +76,7 @@ pub async fn cmd_http_request_body<H: Host>(
}
pub async fn cmd_save_response<H: Host>(host: H, req: CmdSaveResponseReq) -> Result<()> {
let response = host.db().get_http_response(&req.response_id)?;
let response = host.db()?.get_http_response(&req.response_id)?;
let body_path =
response.body_path.ok_or(Error::Generic("Response does not have a body".to_string()))?;
+1 -1
View File
@@ -20,7 +20,7 @@ pub async fn cmd_render_template<H: PluginHost>(
req: CmdRenderTemplateReq,
) -> Result<String> {
let environment_chain =
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
host.db()?.resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
let options = RenderOptions {
// A preview that throws would show the user an error where they expect
+10 -5
View File
@@ -141,13 +141,13 @@ async fn writes_carry_the_client_id() {
// Deletes cascade inside a transaction; make sure that path works with no
// host doing anything special around it.
let workspace = host.db().get_workspace(&id).expect("get workspace");
let workspace = host.db().unwrap().get_workspace(&id).expect("get workspace");
let deleted =
models_delete(host.clone(), ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
.await
.expect("delete");
assert_eq!(deleted, id);
assert!(host.db().get_workspace(&id).is_err(), "workspace should be gone");
assert!(host.db().unwrap().get_workspace(&id).is_err(), "workspace should be gone");
}
#[tokio::test]
@@ -407,6 +407,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
// variable is what proves the chain was resolved rather than skipped.
let environment = host
.db()
.unwrap()
.upsert_environment(
&Environment {
workspace_id: id.clone(),
@@ -439,7 +440,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
// The delete path too, since it is the one that used to reach for a
// blocking thread this host does not have.
let workspace = host.db().get_workspace(&id).expect("get workspace");
let workspace = host.db().unwrap().get_workspace(&id).expect("get workspace");
let deleted = models_delete(host, ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
.await
.expect("delete");
@@ -460,12 +461,14 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
let workspace = host
.db()
.unwrap()
.upsert_workspace(
&Workspace { name: "Auth".to_string(), ..Default::default() },
&host.update_source(),
)
.expect("workspace");
host.db()
.unwrap()
.upsert_environment(
&Environment {
workspace_id: workspace.id.clone(),
@@ -482,7 +485,7 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
)
.expect("environment");
let environment =
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
host.db().unwrap().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
let mut values = HashMap::new();
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
@@ -522,12 +525,14 @@ async fn template_function_values_are_rendered_before_the_host_sees_them() {
let workspace = host
.db()
.unwrap()
.upsert_workspace(
&Workspace { name: "Functions".to_string(), ..Default::default() },
&host.update_source(),
)
.expect("workspace");
host.db()
.unwrap()
.upsert_environment(
&Environment {
workspace_id: workspace.id.clone(),
@@ -544,7 +549,7 @@ async fn template_function_values_are_rendered_before_the_host_sees_them() {
)
.expect("environment");
let environment =
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
host.db().unwrap().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
let mut values = HashMap::new();
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
+6 -4
View File
@@ -51,7 +51,7 @@ impl EncryptionManager {
pub fn set_human_key(&self, workspace_id: &str, human_key: &str) -> Result<WorkspaceMeta> {
let wkey = WorkspaceKey::from_human(human_key)?;
let workspace = self.query_manager.connect().get_workspace(workspace_id)?;
let workspace = self.query_manager.connect()?.get_workspace(workspace_id)?;
let encryption_key_challenge = match workspace.encryption_key_challenge {
None => return self.set_workspace_key(workspace_id, &wkey),
Some(c) => c,
@@ -103,7 +103,7 @@ impl EncryptionManager {
pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
let workspace_meta =
self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?;
self.query_manager.connect()?.get_or_create_workspace_meta(workspace_id)?;
// Already exists
if let Some(_) = workspace_meta.encryption_key {
@@ -152,8 +152,10 @@ impl EncryptionManager {
}
};
let db = self.query_manager.connect();
let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?;
// Scoped so the pooled connection is back before `get_master_key` below, which
// can block on the OS keyring for as long as it likes.
let workspace_meta =
self.query_manager.connect()?.get_or_create_workspace_meta(workspace_id)?;
let key = match workspace_meta.encryption_key {
None => return Err(MissingWorkspaceKey),
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
+4 -4
View File
@@ -77,7 +77,7 @@ mod tests {
#[test]
fn only_the_owner_closes_what_the_last_session_left_open() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let source = &UpdateSource::Background;
let workspace = db
@@ -117,14 +117,14 @@ mod tests {
#[test]
fn owner_without_a_filesystem_still_sweeps_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
{
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect().unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
}
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
assert!(!blob_manager.connect().unwrap().body_exists("rs_gone").unwrap());
}
}
+3
View File
@@ -27,3 +27,6 @@ yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
[dev-dependencies]
tempfile = "3"
+8 -2
View File
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
/**
* `None` once the user has decided not to import this key
*/
modelId?: string;
/**
* Hash of the resource as last applied or decided from the source, if one was recorded
*/
contentHash?: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+82 -23
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string, label: string, };
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string;
label: string;
};
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string },
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
export type ImportPlan = {
importer: string;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/**
* The model types an import plan can contain.
*/
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
@@ -0,0 +1,24 @@
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
-- so a resource the user chose not to import can be remembered.
CREATE TABLE import_source_resources_new
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
content_hash TEXT,
PRIMARY KEY (import_source_id, source_key)
);
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
source_key, model_type, model_id, content_hash)
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
FROM import_source_resources;
DROP TABLE import_source_resources;
ALTER TABLE import_source_resources_new
RENAME TO import_source_resources;
+12 -12
View File
@@ -36,9 +36,9 @@ impl BlobManager {
Self { pool }
}
pub fn connect(&self) -> BlobContext {
let conn = self.pool.get().expect("Failed to get blob DB connection from pool");
BlobContext { conn }
pub fn connect(&self) -> Result<BlobContext> {
let conn = crate::pool::acquire(&self.pool, "blob")?;
Ok(BlobContext { conn })
}
}
@@ -208,7 +208,7 @@ mod tests {
fn test_insert_and_get_chunks() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
let body_id = "rs_test123.request";
let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec());
@@ -229,7 +229,7 @@ mod tests {
fn test_get_chunks_ordered_by_index() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
let body_id = "rs_test123.request";
@@ -249,7 +249,7 @@ mod tests {
fn test_delete_chunks() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
let body_id = "rs_test123.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap();
@@ -266,7 +266,7 @@ mod tests {
fn test_delete_chunks_like() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
// Insert chunks for same response but different body types
ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap();
@@ -288,7 +288,7 @@ mod tests {
fn test_get_body_size() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
let body_id = "rs_test123.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap();
@@ -302,7 +302,7 @@ mod tests {
fn test_get_body_size_empty() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
let size = ctx.get_body_size("nonexistent").unwrap();
assert_eq!(size, 0);
@@ -312,7 +312,7 @@ mod tests {
fn test_body_exists() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
assert!(!ctx.body_exists("rs_test.request").unwrap());
@@ -325,7 +325,7 @@ mod tests {
fn test_multiple_bodies_isolated() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap();
@@ -343,7 +343,7 @@ mod tests {
fn test_large_chunk() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let ctx = manager.connect().unwrap();
// 1MB chunk
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
+12
View File
@@ -9,6 +9,18 @@ pub enum Error {
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] yaak_database::PoolError),
#[error(
"Timed out after {waited_ms}ms waiting for a {what} database connection ({connections} open, {idle} idle)"
)]
PoolTimeout {
what: &'static str,
waited_ms: u128,
connections: u32,
idle: u32,
#[source]
source: yaak_database::PoolError,
},
#[error("Database error: {0}")]
Database(String),
+8 -5
View File
@@ -17,6 +17,7 @@ pub mod migrate;
pub mod models;
pub mod models_ops;
pub mod path_placeholders;
mod pool;
pub mod queries;
pub mod query_manager;
pub mod render;
@@ -108,12 +109,14 @@ pub fn init_standalone(
let db_path = db_path.as_ref();
let blob_path = blob_path.as_ref();
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
// features — connections are held per-statement, so even heavy fan-out (e.g. many
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
// Main database pool. Sized for concurrent in-flight commands, not concurrent app
// features: connections are held per-statement, but one command can nest a couple of
// them, and since every frontend command dispatches as its own task they all land at
// once. Only `min_idle` is paid up front — r2d2 opens the rest on demand — and the
// app raises its open-file limit to 10240 at startup, so the old 256 fd ceiling that
// kept this small no longer applies.
info!("Initializing app database {db_path:?}");
let pool = open::file_pool(db_path, 20, 2)?;
let pool = open::file_pool(db_path, 50, 2)?;
migrate_db(&pool)?;
info!("Initializing blobs database {blob_path:?}");
+7 -3
View File
@@ -3118,8 +3118,12 @@ pub struct ImportSourceResource {
pub import_source_id: String,
pub source_key: String,
pub model_type: String,
pub model_id: String,
pub snapshot: String,
/// `None` once the user has decided not to import this key
#[ts(optional)]
pub model_id: Option<String>,
/// Hash of the resource as last applied or decided from the source, if one was recorded
#[ts(optional)]
pub content_hash: Option<String>,
}
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
source_key: r.get("source_key")?,
model_type: r.get("model_type")?,
model_id: r.get("model_id")?,
snapshot: r.get("snapshot")?,
content_hash: r.get("content_hash")?,
})
}
}
+48
View File
@@ -0,0 +1,48 @@
//! One way in and out of a connection pool, so every acquisition is timed the same way.
//!
//! A pool that cannot hand out a connection fails with a timeout and nothing else: r2d2
//! reports `Error(None)` whether all the connections are checked out or none could be
//! opened in the first place. Those have nothing to do with each other, so the state of
//! the pool at the moment it gave up rides along in the error and in the log line.
use crate::error::{Error, Result};
use log::warn;
use std::time::{Duration, Instant};
use yaak_database::{SqliteConn, SqlitePool, pool_status};
/// Long enough that a healthy app never logs, short enough to catch a pool going bad
/// well before the acquire timeout turns it into an error.
const SLOW_ACQUIRE: Duration = Duration::from_secs(1);
pub(crate) fn acquire(pool: &SqlitePool, what: &'static str) -> Result<SqliteConn> {
let started = Instant::now();
let result = pool.get();
let waited = started.elapsed();
match result {
Ok(conn) => {
if waited >= SLOW_ACQUIRE {
warn!(
"Waited {}ms for a {what} connection ({})",
waited.as_millis(),
pool_status(pool)
);
}
Ok(conn)
}
Err(source) => {
let status = pool_status(pool);
warn!(
"Gave up after {}ms waiting for a {what} connection ({status})",
waited.as_millis()
);
Err(Error::PoolTimeout {
what,
waited_ms: waited.as_millis(),
connections: status.connections,
idle: status.idle,
source,
})
}
}
}
@@ -190,7 +190,7 @@ mod tests {
#[test]
fn request_resolution_preserves_duplicate_request_headers() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0);
let request = HttpRequest {
workspace_id: workspace.id,
@@ -225,7 +225,7 @@ mod tests {
#[test]
fn http_version_resolves_through_the_inheritance_chain() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let workspace = db
.upsert_workspace(
@@ -57,7 +57,7 @@ impl<'a> ClientDb<'a> {
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
let mut deleted = 0;
let blob_ctx = blobs.connect();
let blob_ctx = blobs.connect()?;
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
@@ -138,7 +138,7 @@ impl<'a> ClientDb<'a> {
}
// Delete request body blobs (pattern: {response_id}.request)
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect()?;
let body_id = format!("{}.request", http_response.id);
if let Err(e) = blob_ctx.delete_chunks(&body_id) {
error!("Failed to delete request body blobs: {}", e);
@@ -224,14 +224,14 @@ mod tests {
#[test]
fn deletes_orphaned_response_body_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let live = seed_live_response(&db, &blob_manager);
let live_request_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect().unwrap();
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
blob_ctx
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
@@ -245,7 +245,7 @@ mod tests {
.expect("Failed to GC response body blobs");
assert_eq!(deleted, 2);
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect().unwrap();
assert!(blob_ctx.body_exists(&live.id).unwrap());
assert!(blob_ctx.body_exists(&live_request_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone").unwrap());
@@ -255,14 +255,14 @@ mod tests {
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let live = seed_live_response(&db, &blob_manager);
let live_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect().unwrap();
blob_ctx.insert_chunk(&BodyChunk::new(&live_body_id, 0, b"live".to_vec())).unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
}
@@ -278,7 +278,7 @@ mod tests {
assert_eq!(deleted, 2);
// Live data survives, orphans are gone
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect().unwrap();
assert!(blob_ctx.body_exists(&live_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
assert!(dir.join(&live.id).exists());
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot,
ImportSourceResourceIden::ContentHash,
])
.values_panic([
CurrentTimestamp.into(),
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(),
resource.model_type.as_str().into(),
resource.model_id.as_str().into(),
resource.snapshot.as_str().into(),
resource.model_id.clone().into(),
resource.content_hash.clone().into(),
])
.on_conflict(
OnConflict::columns([
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot,
ImportSourceResourceIden::ContentHash,
])
.to_owned(),
)
@@ -104,7 +104,7 @@ mod tests {
#[test]
fn records_model_changes_for_upsert_and_delete() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let workspace = db
.upsert_workspace(
@@ -146,7 +146,7 @@ mod tests {
#[test]
fn prunes_old_model_changes() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
db.upsert_workspace(
&Workspace {
@@ -179,7 +179,7 @@ mod tests {
#[test]
fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let workspace = db
.upsert_workspace(
@@ -213,7 +213,7 @@ mod tests {
#[test]
fn prunes_old_model_changes_by_hours() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
db.upsert_workspace(
&Workspace {
@@ -245,7 +245,7 @@ mod tests {
#[test]
fn list_model_changes_deserializes_http_response_event_payload() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let payload = json!({
"model": {
+2 -2
View File
@@ -109,7 +109,7 @@ impl<'a> ClientDb<'a> {
// Best-effort cleanup of response bodies (disk files and blob chunks).
// Failures only orphan unreferenced data, and are logged.
let blob_ctx = blobs.connect();
let blob_ctx = blobs.connect()?;
for m in responses {
if let Some(p) = m.body_path {
if let Err(e) = std::fs::remove_file(&p) {
@@ -215,7 +215,7 @@ mod tests {
#[test]
fn bootstraps_first_workspace_with_real_defaults() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let db = query_manager.connect().unwrap();
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
let workspace = workspaces.first().expect("No workspace was bootstrapped");
+104 -17
View File
@@ -19,22 +19,10 @@ impl QueryManager {
QueryManager { pool, events_tx }
}
pub fn connect(&self) -> ClientDb<'_> {
let conn = self.pool.get().expect("Failed to get a new DB connection from the pool");
pub fn connect(&self) -> crate::error::Result<ClientDb<'_>> {
let conn = crate::pool::acquire(&self.pool, "model")?;
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
ClientDb::new(ctx, self.events_tx.clone())
}
pub fn with_conn<F, T>(&self, func: F) -> T
where
F: FnOnce(&ClientDb) -> T,
{
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
let db = ClientDb::new(ctx, self.events_tx.clone());
func(&db)
Ok(ClientDb::new(ctx, self.events_tx.clone()))
}
pub fn with_tx<T, E>(
@@ -44,11 +32,11 @@ impl QueryManager {
where
E: From<crate::error::Error>,
{
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
let conn = crate::pool::acquire(&self.pool, "model")?;
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.expect("Failed to start DB transaction");
.map_err(|e| GenericError(format!("Failed to start DB transaction: {e:?}")))?;
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
let db = ClientDb::new(ctx, self.events_tx.clone());
@@ -67,3 +55,102 @@ impl QueryManager {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::migrate::migrate_db;
use crate::models::Workspace;
use crate::util::UpdateSource;
use r2d2_sqlite::SqliteConnectionManager;
use std::time::Duration;
/// A one-connection pool that gives up quickly, so exhaustion is reachable in a test
/// without waiting out the app's acquire timeout.
fn one_connection_pool(
dir: &std::path::Path,
) -> (QueryManager, mpsc::Receiver<ModelPayload>) {
let manager = SqliteConnectionManager::file(dir.join("db.sqlite"));
let pool = r2d2::Pool::builder()
.max_size(1)
.connection_timeout(Duration::from_millis(100))
.build(manager)
.expect("build pool");
migrate_db(&pool).expect("migrate");
let (tx, rx) = mpsc::channel();
(QueryManager::new(pool, tx), rx)
}
fn workspace(name: &str) -> Workspace {
Workspace { name: name.to_string(), ..Default::default() }
}
#[test]
fn exhausted_pool_reports_an_error_instead_of_panicking() {
let dir = tempfile::tempdir().expect("tempdir");
let (qm, _events) = one_connection_pool(dir.path());
let held = qm.connect().expect("first connection");
match qm.connect() {
Ok(_) => panic!("pool should be exhausted"),
Err(e) => {
// The state of the pool rides along, so a report says which failure it was
assert!(
matches!(e, Error::PoolTimeout { connections: 1, idle: 0, .. }),
"got {e:?}"
);
}
}
let err = qm
.with_tx::<(), Error>(|db| {
db.upsert_workspace(&workspace("never runs"), &UpdateSource::Background)?;
Ok(())
})
.expect_err("pool is exhausted");
assert!(matches!(err, Error::PoolTimeout { .. }), "got {err:?}");
drop(held);
qm.connect().expect("pool recovers once the connection is back");
}
#[test]
fn with_tx_returns_its_connection_on_every_exit() {
let dir = tempfile::tempdir().expect("tempdir");
let (qm, _events) = one_connection_pool(dir.path());
qm.with_tx::<(), Error>(|db| {
db.upsert_workspace(&workspace("committed"), &UpdateSource::Background)?;
Ok(())
})
.expect("commit");
let rolled_back = qm.with_tx::<(), Error>(|db| {
db.upsert_workspace(&workspace("rolled back"), &UpdateSource::Background)?;
Err(Error::Unknown)
});
assert!(rolled_back.is_err());
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
qm.with_tx::<(), Error>(|db| {
db.upsert_workspace(&workspace("panicked"), &UpdateSource::Background)?;
panic!("closure blew up");
})
}));
assert!(panicked.is_err());
// The pool holds exactly one connection, so this only succeeds if all three
// transactions above gave theirs back.
let names = qm
.connect()
.expect("connection is back in the pool")
.list_workspaces()
.expect("list")
.into_iter()
.map(|w| w.name)
.collect::<Vec<_>>();
assert_eq!(names, vec!["committed".to_string()]);
}
}
+45
View File
@@ -109,6 +109,36 @@ pub enum ImportDestination {
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
#[serde(default)]
pub level: ImportPlanWarningLevel,
}
/// Whether a plan's note is something to know or something to think twice about.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanWarningLevel {
#[default]
Info,
Warning,
}
impl ImportPlanWarning {
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
level: ImportPlanWarningLevel::Info,
}
}
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
level: ImportPlanWarningLevel::Warning,
}
}
}
/// Where an import's contents came from, used to link the committed workspace back to it.
@@ -169,6 +199,16 @@ pub enum ImportPlanAction {
Unchanged,
KeepLocal,
Conflict,
/// Present in the source but previously turned down; selecting it imports it again
Ignored,
}
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanReason {
MovedIntoIgnoredFolder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
@@ -193,6 +233,11 @@ pub struct ImportPlanItem {
pub selected: bool,
#[ts(optional)]
pub resolution: Option<ImportConflictResolution>,
#[ts(optional)]
pub reason: Option<ImportPlanReason>,
/// Fields where the source and the local copy disagree, so the preview can say why
#[serde(default)]
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
+13
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+2 -2
View File
@@ -26,7 +26,7 @@ pub async fn delete_and_uninstall(
};
// Scope the db connection so it doesn't live across await
let plugin = {
let db = query_manager.connect();
let db = query_manager.connect()?;
db.delete_plugin_by_id(plugin_id, &update_source)?
};
if let Err(err) = plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await {
@@ -74,7 +74,7 @@ pub async fn download_and_install(
// Scope the db connection so it doesn't live across await
let plugin = {
let db = query_manager.connect();
let db = query_manager.connect()?;
db.upsert_plugin(
&Plugin {
id: plugin_version.id.clone(),
+1 -1
View File
@@ -189,7 +189,7 @@ impl PluginManager {
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
// Scope the db connection so the future stays Send across the await below
let plugins = {
let db = query_manager.connect();
let db = query_manager.connect()?;
for dir in &bundled_dirs {
if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin(
+29 -13
View File
@@ -104,7 +104,9 @@ pub async fn boot() -> Result<()> {
let (queries, blobs, events) =
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
if let Err(e) = yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect(), &blobs) {
if let Err(e) =
yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect().map_err(js_error)?, &blobs)
{
web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e));
}
@@ -254,7 +256,7 @@ fn dispatch(
// because that is what the desktop returns and what the store parses.
"models_workspace_models" => {
let req: WorkspaceModelsReq = from_js(payload)?;
let db = host.queries.connect();
let db = host.queries.connect().map_err(js_error)?;
let mut list: Vec<AnyModel> = Vec::new();
list.push(db.get_settings().into());
@@ -291,7 +293,7 @@ fn dispatch(
"models_upsert" => {
let req: ModelReq = from_js(payload)?;
let db = host.queries.connect();
let db = host.queries.connect().map_err(js_error)?;
let id =
models_ops::upsert_model(&db, &host.blobs, req.model, source).map_err(js_error)?;
to_json(id)
@@ -319,11 +321,16 @@ fn dispatch(
to_json(id)
}
"models_get_settings" => to_json(host.queries.connect().get_settings()),
"models_get_settings" => to_json(host.queries.connect().map_err(js_error)?.get_settings()),
"models_get_graphql_introspection" => {
let req: RequestIdReq = from_js(payload)?;
to_json(host.queries.connect().get_graphql_introspection(&req.request_id))
to_json(
host.queries
.connect()
.map_err(js_error)?
.get_graphql_introspection(&req.request_id),
)
}
"models_upsert_graphql_introspection" => {
@@ -331,6 +338,7 @@ fn dispatch(
let saved = host
.queries
.connect()
.map_err(js_error)?
.upsert_graphql_introspection(
&req.workspace_id,
&req.request_id,
@@ -346,7 +354,13 @@ fn dispatch(
"web_get_http_request" => {
let req: RequestIdReq = from_js(payload)?;
to_json(host.queries.connect().get_http_request(&req.request_id).map_err(js_error)?)
to_json(
host.queries
.connect()
.map_err(js_error)?
.get_http_request(&req.request_id)
.map_err(js_error)?,
)
}
"cmd_get_http_response_events" => {
@@ -354,6 +368,7 @@ fn dispatch(
to_json(
host.queries
.connect()
.map_err(js_error)?
.list_http_response_events(&req.response_id)
.map_err(js_error)?,
)
@@ -366,7 +381,7 @@ fn dispatch(
if req.before == req.after {
return to_json(());
}
let db = host.queries.connect();
let db = host.queries.connect().map_err(js_error)?;
let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?;
let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?;
@@ -378,7 +393,7 @@ fn dispatch(
// writes fan out to every tab as `model_writes` like any other.
"web_insert_http_response_events" => {
let req: InsertResponseEventsReq = from_js(payload)?;
let db = host.queries.connect();
let db = host.queries.connect().map_err(js_error)?;
for event in req.events {
let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
db.upsert_http_response_event(&model, source).map_err(js_error)?;
@@ -388,7 +403,7 @@ fn dispatch(
"cmd_get_workspace_meta" => {
let req: WorkspaceIdReq = from_js(payload)?;
let db = host.queries.connect();
let db = host.queries.connect().map_err(js_error)?;
let workspace = db.get_workspace(&req.workspace_id).map_err(js_error)?;
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
}
@@ -397,6 +412,7 @@ fn dispatch(
let req: RequestIdReq = from_js(payload)?;
host.queries
.connect()
.map_err(js_error)?
.delete_all_http_responses_for_request(&req.request_id, source)
.map_err(js_error)?;
to_json(())
@@ -488,7 +504,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
// Everything from the database first, then release the host borrow before rendering.
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
let db = host.queries.connect();
let db = host.queries.connect().map_err(js_error)?;
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
let environment_chain = db
.resolve_environments(
@@ -554,7 +570,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
#[wasm_bindgen]
pub fn blob_get(id: &str) -> Result<Option<Vec<u8>>> {
with_host(|host| {
let chunks = host.blobs.connect().get_chunks(id).map_err(js_error)?;
let chunks = host.blobs.connect().map_err(js_error)?.get_chunks(id).map_err(js_error)?;
if chunks.is_empty() {
return Ok(None);
}
@@ -569,7 +585,7 @@ pub fn blob_get(id: &str) -> Result<Option<Vec<u8>>> {
pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> {
const CHUNK: usize = 512 * 1024;
with_host(|host| {
let ctx = host.blobs.connect();
let ctx = host.blobs.connect().map_err(js_error)?;
ctx.delete_chunks(id).map_err(js_error)?;
for (i, part) in bytes.chunks(CHUNK).enumerate() {
ctx.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec())).map_err(js_error)?;
@@ -580,5 +596,5 @@ pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> {
#[wasm_bindgen]
pub fn blob_delete(id: &str) -> Result<()> {
with_host(|host| host.blobs.connect().delete_chunks(id).map_err(js_error))
with_host(|host| host.blobs.connect().map_err(js_error)?.delete_chunks(id).map_err(js_error))
}
+1
View File
@@ -9,6 +9,7 @@ async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true }
md5 = "0.8.0"
sha2 = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+1 -1
View File
@@ -13,7 +13,7 @@ pub struct ExportDataParams<'a> {
}
pub fn export_data(params: ExportDataParams<'_>) -> Result<()> {
let db = params.query_manager.connect();
let db = params.query_manager.connect()?;
let export_data = get_workspace_export_resources(
&db,
params.yaak_version,
+974 -151
View File
File diff suppressed because it is too large. Load diff
+67 -77
View File
@@ -217,20 +217,33 @@ fn build_shared_reply(
request: SharedRequest<'_>,
context: SharedPluginEventContext<'_>,
) -> InternalEventPayload {
// Each arm takes its own connection and hands it straight back, so an arm that reads a
// response body never holds one while it does. The macro is here so failing to get one
// answers the plugin with an error instead of unwinding the event loop.
macro_rules! db {
() => {
match query_manager.connect() {
Ok(db) => db,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to get a database connection: {err}"),
});
}
}
};
}
match request {
SharedRequest::GetKeyValue(req) => {
let value = query_manager
.connect()
.get_plugin_key_value(context.plugin_name, &req.key)
.map(|v| v.value);
let value = db!().get_plugin_key_value(context.plugin_name, &req.key).map(|v| v.value);
InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })
}
SharedRequest::SetKeyValue(req) => {
query_manager.connect().set_plugin_key_value(context.plugin_name, &req.key, &req.value);
db!().set_plugin_key_value(context.plugin_name, &req.key, &req.value);
InternalEventPayload::SetKeyValueResponse(yaak_plugins::events::SetKeyValueResponse {})
}
SharedRequest::DeleteKeyValue(req) => {
match query_manager.connect().delete_plugin_key_value(context.plugin_name, &req.key) {
match db!().delete_plugin_key_value(context.plugin_name, &req.key) {
Ok(deleted) => {
InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted })
}
@@ -240,7 +253,7 @@ fn build_shared_reply(
}
}
SharedRequest::GetHttpRequestById(req) => {
let http_request = query_manager.connect().get_http_request(&req.id).ok();
let http_request = db!().get_http_request(&req.id).ok();
InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
http_request,
})
@@ -251,7 +264,7 @@ fn build_shared_reply(
error: "workspace_id is required for list_folders_request".to_string(),
});
};
let folders = match query_manager.connect().list_folders(workspace_id) {
let folders = match db!().list_folders(workspace_id) {
Ok(folders) => folders,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -263,7 +276,7 @@ fn build_shared_reply(
}
SharedRequest::ListHttpRequests(req) => {
let http_requests = if let Some(folder_id) = req.folder_id.as_deref() {
match query_manager.connect().list_http_requests_for_folder_recursive(folder_id) {
match db!().list_http_requests_for_folder_recursive(folder_id) {
Ok(http_requests) => http_requests,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -279,7 +292,7 @@ fn build_shared_reply(
.to_string(),
});
};
match query_manager.connect().list_http_requests(workspace_id) {
match db!().list_http_requests(workspace_id) {
Ok(http_requests) => http_requests,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -293,8 +306,7 @@ fn build_shared_reply(
})
}
SharedRequest::FindHttpResponses(req) => {
let http_responses = query_manager
.connect()
let http_responses = db!()
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
.unwrap_or_default();
InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
@@ -330,29 +342,24 @@ fn build_shared_reply(
use AnyModel::*;
let model = match &req.model {
HttpRequest(m) => {
match query_manager.connect().upsert_http_request(m, &UpdateSource::Plugin) {
Ok(model) => HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert HTTP request: {err}"),
});
}
HttpRequest(m) => match db!().upsert_http_request(m, &UpdateSource::Plugin) {
Ok(model) => HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert HTTP request: {err}"),
});
}
}
GrpcRequest(m) => {
match query_manager.connect().upsert_grpc_request(m, &UpdateSource::Plugin) {
Ok(model) => GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert gRPC request: {err}"),
});
}
},
GrpcRequest(m) => match db!().upsert_grpc_request(m, &UpdateSource::Plugin) {
Ok(model) => GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert gRPC request: {err}"),
});
}
}
WebsocketRequest(m) => {
match query_manager.connect().upsert_websocket_request(m, &UpdateSource::Plugin)
{
match db!().upsert_websocket_request(m, &UpdateSource::Plugin) {
Ok(model) => WebsocketRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -361,34 +368,28 @@ fn build_shared_reply(
}
}
}
Folder(m) => {
match query_manager.connect().upsert_folder(m, &UpdateSource::Plugin) {
Ok(model) => Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert folder: {err}"),
});
}
Folder(m) => match db!().upsert_folder(m, &UpdateSource::Plugin) {
Ok(model) => Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert folder: {err}"),
});
}
}
Environment(m) => {
match query_manager.connect().upsert_environment(m, &UpdateSource::Plugin) {
Ok(model) => Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert environment: {err}"),
});
}
},
Environment(m) => match db!().upsert_environment(m, &UpdateSource::Plugin) {
Ok(model) => Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert environment: {err}"),
});
}
}
Workspace(m) => {
match query_manager.connect().upsert_workspace(m, &UpdateSource::Plugin) {
Ok(model) => Workspace(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert workspace: {err}"),
});
}
},
Workspace(m) => match db!().upsert_workspace(m, &UpdateSource::Plugin) {
Ok(model) => Workspace(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert workspace: {err}"),
});
}
}
_ => {
@@ -403,10 +404,7 @@ fn build_shared_reply(
SharedRequest::DeleteModel(req) => {
let model = match req.model.as_str() {
"http_request" => {
match query_manager
.connect()
.delete_http_request_by_id(&req.id, &UpdateSource::Plugin)
{
match db!().delete_http_request_by_id(&req.id, &UpdateSource::Plugin) {
Ok(model) => AnyModel::HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -416,10 +414,7 @@ fn build_shared_reply(
}
}
"grpc_request" => {
match query_manager
.connect()
.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)
{
match db!().delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin) {
Ok(model) => AnyModel::GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -429,10 +424,7 @@ fn build_shared_reply(
}
}
"websocket_request" => {
match query_manager
.connect()
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)
{
match db!().delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin) {
Ok(model) => AnyModel::WebsocketRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -441,10 +433,7 @@ fn build_shared_reply(
}
}
}
"folder" => match query_manager
.connect()
.delete_folder_by_id(&req.id, &UpdateSource::Plugin)
{
"folder" => match db!().delete_folder_by_id(&req.id, &UpdateSource::Plugin) {
Ok(model) => AnyModel::Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -453,10 +442,7 @@ fn build_shared_reply(
}
},
"environment" => {
match query_manager
.connect()
.delete_environment_by_id(&req.id, &UpdateSource::Plugin)
{
match db!().delete_environment_by_id(&req.id, &UpdateSource::Plugin) {
Ok(model) => AnyModel::Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -509,6 +495,7 @@ mod tests {
query_manager
.connect()
.unwrap()
.upsert_workspace(
&Workspace {
id: "wk_test".to_string(),
@@ -521,6 +508,7 @@ mod tests {
query_manager
.connect()
.unwrap()
.upsert_folder(
&Folder {
id: "fl_test".to_string(),
@@ -534,6 +522,7 @@ mod tests {
query_manager
.connect()
.unwrap()
.upsert_http_request(
&HttpRequest {
id: "rq_test".to_string(),
@@ -739,6 +728,7 @@ mod tests {
let existing = query_manager
.connect()
.unwrap()
.get_http_request("rq_test")
.expect("Failed to load seeded request");
let upsert_payload = InternalEventPayload::UpsertModelRequest(UpsertModelRequest {
+7 -4
View File
@@ -63,13 +63,13 @@ impl<'a> FileResponseBodyStore<'a> {
/// request behind it never reaches the store at all, and its bytes come
/// back from the send instead — see `SendHttpRequestResponse::body`.
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
Ok(self.query_manager.connect()?.get_http_response(response_id)?.body_path)
}
}
impl ResponseBodyStore for FileResponseBodyStore<'_> {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
let response = self.query_manager.connect().get_http_response(response_id)?;
let response = self.query_manager.connect()?.get_http_response(response_id)?;
let content_type = response
.headers
@@ -127,6 +127,7 @@ mod tests {
query_manager
.connect()
.unwrap()
.upsert_workspace(
&Workspace { id: "wk_test".to_string(), ..Default::default() },
&UpdateSource::Sync,
@@ -135,6 +136,7 @@ mod tests {
query_manager
.connect()
.unwrap()
.upsert_http_request(
&HttpRequest {
id: "rq_test".to_string(),
@@ -154,6 +156,7 @@ mod tests {
let response = query_manager
.connect()
.unwrap()
.upsert_http_response(
&HttpResponse {
workspace_id: "wk_test".to_string(),
@@ -207,9 +210,9 @@ mod tests {
// Seeded responses default to Initialized: still arriving.
assert!(!FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
let mut response = qm.connect().get_http_response(&id).unwrap();
let mut response = qm.connect().unwrap().get_http_response(&id).unwrap();
response.state = HttpResponseState::Closed;
qm.connect().update_http_response_if_id(&response, &UpdateSource::Sync).unwrap();
qm.connect().unwrap().update_http_response_if_id(&response, &UpdateSource::Sync).unwrap();
assert!(FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
}
+43 -11
View File
@@ -45,6 +45,9 @@ const MAX_AUTH_BODY_BYTES: usize = 10 * 1024 * 1024;
#[derive(Debug, Error)]
pub enum SendHttpRequestError {
#[error("Failed to acquire a database connection: {0}")]
AcquireDatabase(#[source] yaak_models::error::Error),
#[error("Failed to load request: {0}")]
LoadRequest(#[source] yaak_models::error::Error),
@@ -409,7 +412,7 @@ pub fn resolve_send_inputs(
environment_id: Option<&str>,
cookies: Option<Vec<Cookie>>,
) -> Result<HttpSendInputs> {
let db = query_manager.connect();
let db = query_manager.connect().map_err(SendHttpRequestError::AcquireDatabase)?;
let environment_chain = db
.resolve_environments(&request.workspace_id, request.folder_id.as_deref(), environment_id)
@@ -442,7 +445,7 @@ pub fn resolve_inherited_request(
query_manager: &QueryManager,
request: &HttpRequest,
) -> Result<ResolvedHttpRequest> {
let db = query_manager.connect();
let db = query_manager.connect().map_err(SendHttpRequestError::AcquireDatabase)?;
let (authentication_type, authentication, auth_context_id) = db
.resolve_auth_for_http_request(request)
.map_err(SendHttpRequestError::ResolveRequestInheritance)?;
@@ -462,6 +465,7 @@ pub async fn send_http_request_by_id_with_plugins(
let request = params
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.get_http_request(params.request_id)
.map_err(SendHttpRequestError::LoadRequest)?;
@@ -544,6 +548,7 @@ pub async fn send_http_request_by_id<T: TemplateCallback>(
let request = params
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.get_http_request(params.request_id)
.map_err(SendHttpRequestError::LoadRequest)?;
let mut cookie_jar = load_cookie_jar(params.query_manager, params.cookie_jar_id.as_deref())?;
@@ -647,6 +652,7 @@ pub async fn send_http_request<T: TemplateCallback>(
response = store
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_http_response(&response, &store.update_source, store.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
} else if response.id.is_empty() {
@@ -700,8 +706,9 @@ pub async fn send_http_request<T: TemplateCallback>(
&event_workspace_id,
event.clone().into(),
);
if let Err(err) =
query_manager.connect().upsert_http_response_event(&db_event, update_source)
if let Err(err) = query_manager
.connect()
.and_then(|db| db.upsert_http_response_event(&db_event, update_source))
{
warn!("Failed to persist HTTP response event: {}", err);
}
@@ -800,6 +807,7 @@ pub async fn send_http_request<T: TemplateCallback>(
response = store
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_http_response(&connected_response, &store.update_source, store.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
} else {
@@ -887,6 +895,7 @@ pub async fn send_http_request<T: TemplateCallback>(
response = store
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_http_response(
&progress_response,
&store.update_source,
@@ -961,6 +970,7 @@ pub async fn send_http_request<T: TemplateCallback>(
response = store
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_http_response(&final_response, &store.update_source, store.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
} else {
@@ -999,6 +1009,7 @@ pub async fn send_http_request<T: TemplateCallback>(
response = store
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_http_response(&response, &store.update_source, store.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
}
@@ -1027,7 +1038,7 @@ fn persist_request_body_bytes(
return Ok(());
}
let blob_ctx = blob_manager.connect();
let blob_ctx = blob_manager.connect().map_err(|e| e.to_string())?;
let mut offset = 0;
let mut chunk_index: i32 = 0;
while offset < bytes.len() {
@@ -1057,14 +1068,22 @@ async fn persist_request_body_stream(
while buf.len() >= REQUEST_BODY_CHUNK_SIZE {
let data = buf.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
let chunk = BodyChunk::new(&body_id, chunk_index, data);
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
blob_manager
.connect()
.map_err(|e| e.to_string())?
.insert_chunk(&chunk)
.map_err(|e| e.to_string())?;
chunk_index += 1;
}
}
if !buf.is_empty() {
let chunk = BodyChunk::new(&body_id, chunk_index, buf);
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
blob_manager
.connect()
.map_err(|e| e.to_string())?
.insert_chunk(&chunk)
.map_err(|e| e.to_string())?;
}
Ok(total_bytes)
@@ -1087,6 +1106,7 @@ pub fn load_cookie_jar(
query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.get_cookie_jar(cookie_jar_id)
.map(Some)
.map_err(SendHttpRequestError::LoadCookieJar)
@@ -1115,6 +1135,7 @@ pub fn persist_cookies_after_send(
cookie_jar.cookies = cookies;
query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_cookie_jar(cookie_jar, &UpdateSource::Background)
.map_err(SendHttpRequestError::PersistCookieJar)?;
Ok(())
@@ -1210,6 +1231,7 @@ fn persist_response_error(
store
.query_manager
.connect()
.map_err(SendHttpRequestError::AcquireDatabase)?
.upsert_http_response(
&HttpResponse {
state: HttpResponseState::Closed,
@@ -1445,6 +1467,7 @@ mod tests {
query_manager
.connect()
.unwrap()
.upsert_workspace(
&Workspace { id: "wk_test".to_string(), ..Default::default() },
&UpdateSource::Sync,
@@ -1452,6 +1475,7 @@ mod tests {
.expect("Failed to seed workspace");
let cookie_jar = query_manager
.connect()
.unwrap()
.upsert_cookie_jar(
&CookieJar {
id: "cj_test".to_string(),
@@ -1493,8 +1517,11 @@ mod tests {
persist_cookies_after_send(&query_manager, Some(&mut cookie_jar), Some(&store))
.expect("Failed to persist cookies");
let stored =
query_manager.connect().get_cookie_jar("cj_test").expect("Failed to load cookie jar");
let stored = query_manager
.connect()
.unwrap()
.get_cookie_jar("cj_test")
.expect("Failed to load cookie jar");
assert_eq!(stored.cookies.len(), 1);
assert_eq!(stored.cookies[0].name, "session");
assert_eq!(stored.cookies[0].value, "abc123");
@@ -1508,6 +1535,7 @@ mod tests {
cookie_jar.cookies = vec![cookie("original")];
cookie_jar = query_manager
.connect()
.unwrap()
.upsert_cookie_jar(&cookie_jar, &UpdateSource::Sync)
.expect("Failed to seed cookies");
let store = CookieStore::from_cookies(cookie_jar.cookies.clone());
@@ -1515,6 +1543,7 @@ mod tests {
// Someone else updates the jar while the send is in flight.
query_manager
.connect()
.unwrap()
.upsert_cookie_jar(
&CookieJar { cookies: vec![cookie("newer")], ..cookie_jar.clone() },
&UpdateSource::Sync,
@@ -1524,8 +1553,11 @@ mod tests {
persist_cookies_after_send(&query_manager, Some(&mut cookie_jar), Some(&store))
.expect("Failed to persist cookies");
let stored =
query_manager.connect().get_cookie_jar("cj_test").expect("Failed to load cookie jar");
let stored = query_manager
.connect()
.unwrap()
.get_cookie_jar("cj_test")
.expect("Failed to load cookie jar");
assert_eq!(stored.cookies, vec![cookie("newer")]);
}
}
+909 -478
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -135,7 +135,9 @@
"yauzl": "^3.4.0"
},
"overrides": {
"@vitest/mocker": "^4.1.11",
"js-yaml": "^4.3.1",
"underscore": "^1.13.8",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
},
"packageManager": "npm@11.11.1"
+1 -1
View File
@@ -15,7 +15,7 @@
"test": "vp test --run tests"
},
"dependencies": {
"@faker-js/faker": "^10.1.0"
"@faker-js/faker": "^10.5.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
@@ -142,6 +142,7 @@ exports[`template-function-faker > exports all expected template functions 1`] =
"faker.location.longitude",
"faker.location.nearbyGPSCoordinate",
"faker.location.ordinalDirection",
"faker.location.postalAddress",
"faker.location.secondaryAddress",
"faker.location.state",
"faker.location.street",
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.34",
"hono": "^4.13.5",
"zod": "^3.25.76"
},
"devDependencies": {
+1 -1
View File
@@ -9,7 +9,7 @@
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}