mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 13:12:22 +01:00
Compare commits
22
Commits
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
CountBadge,
|
||||
type ExtraPaneTab,
|
||||
type HostBodyViewerArgs,
|
||||
HStack,
|
||||
HttpResponsePaneView,
|
||||
Icon,
|
||||
type ResponseTextViewerExtras,
|
||||
@@ -29,6 +30,7 @@ import { IconButton } from "./core/IconButton";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestVersionDropdown } from "./RequestVersionDropdown";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
|
||||
|
||||
@@ -170,11 +172,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
}
|
||||
statusLineRight={
|
||||
activeResponse != null && (
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
<HStack space={1}>
|
||||
<RequestVersionDropdown response={activeResponse} />
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { stringify } from "yaml";
|
||||
import { useRequestVersion } from "../hooks/useRequestVersion";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
|
||||
import { Button } from "./core/Button";
|
||||
import { DiffViewer } from "./core/Editor/DiffViewer";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, "requestId" | "versionId">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the request a response was sent from, when that is no longer the
|
||||
* request you have.
|
||||
*
|
||||
* Hidden while the two agree, which is the overwhelmingly common case and the
|
||||
* one where there is nothing to say. Responses recorded before versioning
|
||||
* existed have no version and stay quiet forever.
|
||||
*/
|
||||
export function RequestVersionDropdown({ response }: Props) {
|
||||
const comparison = useRequestVersion(response.versionId, response.requestId);
|
||||
if (comparison == null || !comparison.differs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "View Diff",
|
||||
leftSlot: <Icon icon="git_branch" />,
|
||||
onSelect: () => showRequestVersionDiff(comparison),
|
||||
},
|
||||
{
|
||||
label: "Restore This Version",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: () => restoreRequestVersion(comparison.version),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="notice"
|
||||
className="font-sans"
|
||||
title="This request has changed since this response was sent"
|
||||
forDropdown
|
||||
>
|
||||
Request Changed
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function showRequestVersionDiff(comparison: RequestVersionComparison) {
|
||||
showDialog({
|
||||
id: "request-version-diff",
|
||||
title: "Request Changes Since This Response",
|
||||
size: "full",
|
||||
noPadding: true,
|
||||
render: () => (
|
||||
<div className="h-full flex flex-col px-4 pb-4">
|
||||
<DiffViewer
|
||||
original={toYaml(comparison.version.document)}
|
||||
modified={toYaml(comparison.currentDocument)}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Matches how the Git dialog renders a model for diffing. */
|
||||
function toYaml(document: unknown): string {
|
||||
return stringify(document, { indent: 2, lineWidth: 0 });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { atom, getDefaultStore } from "jotai";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rpc: vi.fn<(cmd: string, args: { versionId: string }) => Promise<unknown>>(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
|
||||
vi.mock("./useAllRequests", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
return { allRequestsAtom: atom([{ id: "rq_1", updatedAt: "t1" }]) };
|
||||
});
|
||||
|
||||
function comparison(versionId: string): RequestVersionComparison {
|
||||
return {
|
||||
version: { id: versionId, modelId: "rq_1" },
|
||||
currentDocument: {},
|
||||
differs: true,
|
||||
} as unknown as RequestVersionComparison;
|
||||
}
|
||||
|
||||
/** Each version's comparison resolves only when the test says so. */
|
||||
function deferredRpc() {
|
||||
const pending = new Map<string, () => void>();
|
||||
mocks.rpc.mockImplementation(
|
||||
(_cmd, { versionId }) =>
|
||||
new Promise((resolve) => pending.set(versionId, () => resolve(comparison(versionId)))),
|
||||
);
|
||||
return async (versionId: string) => {
|
||||
await vi.waitFor(() => expect(pending.has(versionId)).toBe(true));
|
||||
await act(async () => pending.get(versionId)?.());
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRequestVersion", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let seen: (RequestVersionComparison | null)[];
|
||||
let useRequestVersion: typeof import("./useRequestVersion").useRequestVersion;
|
||||
let allRequestsAtom: ReturnType<typeof atom<{ id: string; updatedAt: string }[]>>;
|
||||
const queryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
let client: QueryClient;
|
||||
|
||||
function Probe({ versionId }: { versionId: string | null }) {
|
||||
seen.push(useRequestVersion(versionId, "rq_1"));
|
||||
return null;
|
||||
}
|
||||
|
||||
async function show(versionId: string | null) {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe versionId={versionId} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const current = () => seen[seen.length - 1];
|
||||
|
||||
/** Wait for the comparison for a version to reach the component. */
|
||||
async function answered(versionId: string) {
|
||||
await vi.waitFor(() => expect(current()?.version.id).toBe(versionId));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
({ useRequestVersion } = await import("./useRequestVersion"));
|
||||
({ allRequestsAtom } = (await import("./useAllRequests")) as unknown as {
|
||||
allRequestsAtom: typeof allRequestsAtom;
|
||||
});
|
||||
getDefaultStore().set(allRequestsAtom, [{ id: "rq_1", updatedAt: "t1" }]);
|
||||
client = queryClient();
|
||||
seen = [];
|
||||
container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
mocks.rpc.mockReset();
|
||||
});
|
||||
|
||||
test("a response with no version has no comparison, even right after one that had", async () => {
|
||||
const resolve = deferredRpc();
|
||||
await show("mv_a");
|
||||
await resolve("mv_a");
|
||||
await answered("mv_a");
|
||||
|
||||
seen = [];
|
||||
await show(null);
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.every((c) => c == null)).toBe(true);
|
||||
});
|
||||
|
||||
test("switching versions never shows the previous version's comparison", async () => {
|
||||
const resolve = deferredRpc();
|
||||
await show("mv_a");
|
||||
await resolve("mv_a");
|
||||
await answered("mv_a");
|
||||
|
||||
seen = [];
|
||||
await show("mv_b");
|
||||
// Still loading: nothing, rather than mv_a's answer
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.every((c) => c == null)).toBe(true);
|
||||
|
||||
await resolve("mv_b");
|
||||
await answered("mv_b");
|
||||
});
|
||||
|
||||
test("an edit to the live request keeps the answer while it refetches", async () => {
|
||||
const resolve = deferredRpc();
|
||||
await show("mv_a");
|
||||
await resolve("mv_a");
|
||||
await answered("mv_a");
|
||||
|
||||
seen = [];
|
||||
await act(async () =>
|
||||
getDefaultStore().set(allRequestsAtom, [{ id: "rq_1", updatedAt: "t2" }]),
|
||||
);
|
||||
// The refetch is pending, but the dropdown does not flicker away meanwhile
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.every((c) => c?.version.id === "mv_a")).toBe(true);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { allRequestsAtom } from "./useAllRequests";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
/**
|
||||
* The request version a response was sent from, alongside the request as it
|
||||
* stands now.
|
||||
*
|
||||
* Refetches when the live request is written, which is what keeps the "has this
|
||||
* changed?" answer honest while someone edits. The comparison itself is the
|
||||
* backend's — the frontend never hashes anything.
|
||||
*
|
||||
* Only ever answers for the version asked about: a response with no version
|
||||
* gets nothing, and switching responses never shows the last one's comparison
|
||||
* while the new one loads.
|
||||
*/
|
||||
export function useRequestVersion(
|
||||
versionId: string | null | undefined,
|
||||
requestId: string | null,
|
||||
): RequestVersionComparison | null {
|
||||
const requests = useAtomValue(allRequestsAtom);
|
||||
const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
|
||||
|
||||
const { data } = useQuery({
|
||||
// Keep the last answer across edits to the live request, but never across
|
||||
// versions, or a response would borrow another response's comparison
|
||||
placeholderData: (prev, prevQuery) => (prevQuery?.queryKey[1] === versionId ? prev : undefined),
|
||||
queryKey: ["request_version", versionId, liveUpdatedAt],
|
||||
enabled: versionId != null,
|
||||
queryFn: () =>
|
||||
rpc<RequestVersionComparison>("models_request_version", { versionId: versionId! }),
|
||||
});
|
||||
|
||||
return versionId != null && data?.version.id === versionId ? data : null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ModelVersionReason } from "@yaakapp-internal/models";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { EditSessionTracker } from "./editSessionTracker";
|
||||
|
||||
type Capture = [requestId: string, reason: ModelVersionReason];
|
||||
|
||||
function tracker(idleMs = 1000) {
|
||||
const captured: Capture[] = [];
|
||||
return {
|
||||
captured,
|
||||
tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs),
|
||||
};
|
||||
}
|
||||
|
||||
describe("EditSessionTracker", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
test("captures a request once it has been left alone", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteEdit("rq_1");
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(captured).toEqual([["rq_1", "idle"]]);
|
||||
});
|
||||
|
||||
test("a burst of edits is one capture, not one per keystroke", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
t.noteEdit("rq_1");
|
||||
vi.advanceTimersByTime(500);
|
||||
}
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(captured).toEqual([["rq_1", "idle"]]);
|
||||
});
|
||||
|
||||
test("each request keeps its own countdown when two are edited in turn", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteEdit("rq_a");
|
||||
vi.advanceTimersByTime(600);
|
||||
// An edit to B, from this window, another one, or sync, must not touch A's countdown
|
||||
t.noteEdit("rq_b");
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(captured).toEqual([["rq_a", "idle"]]);
|
||||
|
||||
// B keeps writing; A is done and stays done
|
||||
t.noteEdit("rq_b");
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(captured).toEqual([["rq_a", "idle"]]);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(captured).toEqual([
|
||||
["rq_a", "idle"],
|
||||
["rq_b", "idle"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("a boundary capture settles that request's countdown, and only that one", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_a");
|
||||
t.noteEdit("rq_a");
|
||||
t.noteEdit("rq_b");
|
||||
t.noteActiveRequest("rq_c");
|
||||
expect(captured).toEqual([["rq_a", "switch"]]);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
// A was already captured when it was left; B's countdown still ran
|
||||
expect(captured).toEqual([
|
||||
["rq_a", "switch"],
|
||||
["rq_b", "idle"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("a close can bring its own snapshot", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
const onClose: Capture[] = [];
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteEdit("rq_1");
|
||||
t.noteBoundary((id, reason) => onClose.push([id, reason]));
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onClose).toEqual([["rq_1", "switch"]]);
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
|
||||
test("captures the request being left, not the one being opened", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
t.noteActiveRequest("rq_2");
|
||||
expect(captured).toEqual([["rq_1", "switch"]]);
|
||||
});
|
||||
|
||||
test("re-selecting the same request is not a boundary", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteActiveRequest("rq_1");
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
|
||||
test("blur and close capture the request still on screen", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteBoundary();
|
||||
t.noteBoundary();
|
||||
expect(captured).toEqual([
|
||||
["rq_1", "switch"],
|
||||
["rq_1", "switch"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("nothing is captured before a request is open", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteBoundary();
|
||||
t.noteActiveRequest(null);
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ModelVersionReason } from "@yaakapp-internal/models";
|
||||
|
||||
/**
|
||||
* How long a request has to sit untouched before its edits become a version.
|
||||
* Long enough that typing a URL is one version rather than forty, short enough
|
||||
* that walking away from a half-finished edit still records it.
|
||||
*/
|
||||
export const IDLE_MS = 60_000;
|
||||
|
||||
type Snapshot = (requestId: string, reason: ModelVersionReason) => void;
|
||||
|
||||
/**
|
||||
* When a request's editing session ends.
|
||||
*
|
||||
* The backend versions a request on every send, which covers "what produced
|
||||
* this response". This covers the rest: an edit someone made and then walked
|
||||
* away from, which no send would ever have captured.
|
||||
*
|
||||
* It deliberately knows nothing about *what* changed. Versions are
|
||||
* content-addressed, so a boundary that turns out to have nothing behind it
|
||||
* costs one query and creates nothing — which is what lets this stay a few
|
||||
* timers instead of a change-tracking system.
|
||||
*/
|
||||
export class EditSessionTracker {
|
||||
/**
|
||||
* One idle countdown per request. Writes arrive for any request — from other
|
||||
* windows and sync too — and an edit to one must never restart or cancel
|
||||
* another's countdown.
|
||||
*/
|
||||
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private activeRequestId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly snapshot: Snapshot,
|
||||
private readonly idleMs: number = IDLE_MS,
|
||||
) {}
|
||||
|
||||
/** A request was written. Restarts that request's idle countdown. */
|
||||
noteEdit(requestId: string) {
|
||||
this.clearTimer(requestId);
|
||||
this.timers.set(
|
||||
requestId,
|
||||
setTimeout(() => {
|
||||
this.timers.delete(requestId);
|
||||
this.snapshot(requestId, "idle");
|
||||
}, this.idleMs),
|
||||
);
|
||||
}
|
||||
|
||||
/** The user moved to a different request, so the one they left is finished. */
|
||||
noteActiveRequest(requestId: string | null) {
|
||||
if (requestId === this.activeRequestId) return;
|
||||
const left = this.activeRequestId;
|
||||
this.activeRequestId = requestId;
|
||||
if (left != null) this.capture(left, this.snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window lost focus or is closing. A close can't wait for anything, so it
|
||||
* passes its own snapshot function.
|
||||
*/
|
||||
noteBoundary(snapshot: Snapshot = this.snapshot) {
|
||||
if (this.activeRequestId != null) this.capture(this.activeRequestId, snapshot);
|
||||
}
|
||||
|
||||
/** Capture at a boundary, which also settles any idle countdown still pending */
|
||||
private capture(requestId: string, snapshot: Snapshot) {
|
||||
this.clearTimer(requestId);
|
||||
snapshot(requestId, "switch");
|
||||
}
|
||||
|
||||
private clearTimer(requestId: string) {
|
||||
const timer = this.timers.get(requestId);
|
||||
if (timer == null) return;
|
||||
clearTimeout(timer);
|
||||
this.timers.delete(requestId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
finishWrites: null as (() => void) | null,
|
||||
flushAllModelWrites: vi.fn(),
|
||||
rpc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/models", () => ({
|
||||
flushAllModelWrites: mocks.flushAllModelWrites,
|
||||
flushAllPendingPatches: vi.fn(),
|
||||
}));
|
||||
vi.mock("@yaakapp-internal/platform", () => ({ platform: {} }));
|
||||
vi.mock("../hooks/useActiveRequestId", () => ({ activeRequestIdAtom: null }));
|
||||
vi.mock("./jotai", () => ({ jotaiStore: {} }));
|
||||
vi.mock("./rpc", () => ({ rpc: mocks.rpc }));
|
||||
|
||||
describe("snapshotRequestVersion", () => {
|
||||
beforeEach(() => {
|
||||
mocks.calls = [];
|
||||
mocks.flushAllModelWrites.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
mocks.calls.push("flush");
|
||||
mocks.finishWrites = () => {
|
||||
mocks.calls.push("writes landed");
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
mocks.rpc.mockImplementation(async (cmd: string) => {
|
||||
mocks.calls.push(cmd);
|
||||
return { id: "mv_1" };
|
||||
});
|
||||
});
|
||||
|
||||
test("asks for the snapshot only once pending writes have landed", async () => {
|
||||
const { snapshotRequestVersion } = await import("./requestVersions");
|
||||
snapshotRequestVersion("rq_1", "switch");
|
||||
await Promise.resolve();
|
||||
expect(mocks.calls).toEqual(["flush"]);
|
||||
|
||||
mocks.finishWrites?.();
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.calls).toEqual(["flush", "writes landed", "models_snapshot_request"]),
|
||||
);
|
||||
expect(mocks.rpc).toHaveBeenCalledWith("models_snapshot_request", {
|
||||
requestId: "rq_1",
|
||||
reason: "switch",
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed write still lets the snapshot go ahead", async () => {
|
||||
mocks.flushAllModelWrites.mockRejectedValue(new Error("write failed"));
|
||||
const { snapshotRequestVersion } = await import("./requestVersions");
|
||||
snapshotRequestVersion("rq_1", "idle");
|
||||
await vi.waitFor(() => expect(mocks.rpc).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { flushAllModelWrites, flushAllPendingPatches } from "@yaakapp-internal/models";
|
||||
import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { EditSessionTracker } from "./editSessionTracker";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
|
||||
|
||||
/**
|
||||
* Ask the backend to capture a request's current content.
|
||||
*
|
||||
* Quiet by design: a version that fails to write is not worth a toast, because
|
||||
* every caller below is reacting to the user leaving rather than asking for
|
||||
* anything.
|
||||
*/
|
||||
export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
|
||||
captureRequestVersion(requestId, reason).catch((err: unknown) => {
|
||||
console.warn("Failed to snapshot request version", err);
|
||||
});
|
||||
}
|
||||
|
||||
async function captureRequestVersion(requestId: string, reason: ModelVersionReason) {
|
||||
// Edits reach the database on a debounce. Wait until every pending write has
|
||||
// landed, or the snapshot can read the row before the edit this boundary is
|
||||
// meant to capture. A write that failed leaves nothing newer to wait for.
|
||||
await flushAllModelWrites().catch((err: unknown) => {
|
||||
console.warn("A pending write failed before a request snapshot", err);
|
||||
});
|
||||
await rpc<ModelVersion>("models_snapshot_request", { requestId, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, for a window that is closing, where nothing can be awaited.
|
||||
*
|
||||
* The pending writes go out first and the snapshot right behind them. In the
|
||||
* browser that is enough: the worker runs commands in the order they arrive.
|
||||
* On the desktop each command runs on its own, so the snapshot can still reach
|
||||
* the database before the last write does. The edit itself is saved either
|
||||
* way, and the request's next send versions it.
|
||||
*/
|
||||
function snapshotRequestVersionOnClose(requestId: string, reason: ModelVersionReason) {
|
||||
flushAllPendingPatches();
|
||||
rpc<ModelVersion>("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
|
||||
console.warn("Failed to snapshot request version", err);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRequestVersionSnapshots() {
|
||||
const tracker = new EditSessionTracker(snapshotRequestVersion);
|
||||
|
||||
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||
for (const payload of payloads) {
|
||||
if (payload.change.type !== "upsert") continue;
|
||||
if (!REQUEST_MODELS.includes(payload.model.model)) continue;
|
||||
tracker.noteEdit(payload.model.id);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||
jotaiStore.sub(activeRequestIdAtom, () => {
|
||||
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||
});
|
||||
|
||||
platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) tracker.noteBoundary();
|
||||
});
|
||||
|
||||
// Closing is the last boundary there is. Nothing can be awaited here, but the
|
||||
// write is already on its way and the backend outlives the window.
|
||||
window.addEventListener("beforeunload", () =>
|
||||
tracker.noteBoundary(snapshotRequestVersionOnClose),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ModelVersion } from "@yaakapp-internal/models";
|
||||
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
flushAllModelWrites: vi.fn(),
|
||||
mergeModelsInStore: vi.fn(),
|
||||
wasUpdatedExternally: vi.fn(),
|
||||
rpc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/models", () => ({
|
||||
flushAllModelWrites: mocks.flushAllModelWrites,
|
||||
mergeModelsInStore: mocks.mergeModelsInStore,
|
||||
}));
|
||||
vi.mock("../hooks/useRequestUpdateKey", () => ({
|
||||
wasUpdatedExternally: mocks.wasUpdatedExternally,
|
||||
}));
|
||||
vi.mock("./rpc", () => ({ rpc: mocks.rpc }));
|
||||
vi.mock("./toast", () => ({ showToast: vi.fn() }));
|
||||
|
||||
const restored = { model: "http_request", id: "rq_1", url: "https://example.com/one" };
|
||||
const version = { id: "mv_1", modelId: "rq_1" } as ModelVersion;
|
||||
|
||||
describe("applyRequestVersion", () => {
|
||||
beforeEach(() => {
|
||||
mocks.calls = [];
|
||||
mocks.flushAllModelWrites.mockImplementation(async () => mocks.calls.push("flush"));
|
||||
mocks.rpc.mockImplementation(async (cmd: string) => {
|
||||
mocks.calls.push(cmd);
|
||||
return restored;
|
||||
});
|
||||
mocks.mergeModelsInStore.mockImplementation(() => mocks.calls.push("store"));
|
||||
mocks.wasUpdatedExternally.mockImplementation(() => mocks.calls.push("editors"));
|
||||
});
|
||||
|
||||
test("puts the restored request in the store before editors re-read it", async () => {
|
||||
const { applyRequestVersion } = await import("./restoreRequestVersion");
|
||||
await applyRequestVersion(version);
|
||||
|
||||
expect(mocks.calls).toEqual(["flush", "models_restore_request_version", "store", "editors"]);
|
||||
expect(mocks.mergeModelsInStore).toHaveBeenCalledWith("http_request", [restored]);
|
||||
expect(mocks.wasUpdatedExternally).toHaveBeenCalledWith("rq_1");
|
||||
});
|
||||
|
||||
test("a failed restore leaves the store and editors alone", async () => {
|
||||
mocks.rpc.mockRejectedValue(new Error("no such version"));
|
||||
const { applyRequestVersion } = await import("./restoreRequestVersion");
|
||||
|
||||
await expect(applyRequestVersion(version)).rejects.toThrow("no such version");
|
||||
expect(mocks.mergeModelsInStore).not.toHaveBeenCalled();
|
||||
expect(mocks.wasUpdatedExternally).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { flushAllModelWrites, mergeModelsInStore } from "@yaakapp-internal/models";
|
||||
import type { AnyModel, ModelVersion } from "@yaakapp-internal/models";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { rpc } from "./rpc";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
/**
|
||||
* Put an old version's content back into the live request.
|
||||
*
|
||||
* The backend captures whatever the request currently holds before
|
||||
* overwriting it, so this is not a destructive action even when the last edit
|
||||
* was never versioned — but the request is still rewritten under the user's
|
||||
* cursor, so it is announced.
|
||||
*/
|
||||
export function restoreRequestVersion(version: ModelVersion) {
|
||||
fireAndForget(
|
||||
(async () => {
|
||||
await applyRequestVersion(version);
|
||||
showToast({
|
||||
id: "request-version-restored",
|
||||
color: "success",
|
||||
message: "Restored the request that produced this response",
|
||||
});
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The restore itself, in the order the editors need it. */
|
||||
export async function applyRequestVersion(version: ModelVersion): Promise<void> {
|
||||
// The backend restores from the database, so anything still sitting in a
|
||||
// debounce has to land first — otherwise it would overwrite the restore
|
||||
await flushAllModelWrites();
|
||||
const restored = await rpc<AnyModel>("models_restore_request_version", {
|
||||
versionId: version.id,
|
||||
});
|
||||
// Editors re-read from the store when told to, and the change event can
|
||||
// arrive after this resolves, so put the restored request there first. The
|
||||
// event re-applying it later changes nothing.
|
||||
mergeModelsInStore(restored.model, [restored]);
|
||||
// The write came from this window, so the store's echo suppression would
|
||||
// otherwise leave open editors showing what was there before
|
||||
wasUpdatedExternally(restored.id);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { initGit } from "./init/git";
|
||||
import { initSync } from "./init/sync";
|
||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||
import { initRequestVersionSnapshots } from "./lib/requestVersions";
|
||||
import { jotaiStore } from "./lib/jotai";
|
||||
import { router } from "./lib/router";
|
||||
|
||||
@@ -36,6 +37,7 @@ initGit();
|
||||
initSync();
|
||||
initModelStore(jotaiStore);
|
||||
initGlobalListeners();
|
||||
initRequestVersionSnapshots();
|
||||
await changeModelStoreWorkspace(null); // Load global models
|
||||
|
||||
console.log("Creating React root");
|
||||
|
||||
@@ -5,7 +5,9 @@ use rand::RngExt;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak::send::{
|
||||
SendHttpRequestWithPluginsParams, SendRequestSource, send_http_request_with_plugins,
|
||||
};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_mcp::{HostResult, McpHost, WorkspaceInfo};
|
||||
@@ -215,6 +217,8 @@ impl McpHost for CliMcpHost {
|
||||
query_manager: &self.query_manager,
|
||||
blob_manager: &self.blob_manager,
|
||||
request,
|
||||
// The MCP server loads the request by id and passes the row on as it is
|
||||
request_source: SendRequestSource::Stored,
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: UpdateSource::Sync,
|
||||
cookie_jar_id,
|
||||
|
||||
@@ -14,7 +14,9 @@ use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak::send::{
|
||||
SendHttpRequestWithPluginsParams, SendRequestSource, send_http_request_with_plugins,
|
||||
};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
@@ -255,6 +257,8 @@ async fn build_plugin_reply(
|
||||
query_manager: &host_context.query_manager,
|
||||
blob_manager: &host_context.blob_manager,
|
||||
request: http_request,
|
||||
// A plugin may have rendered it, so it is versioned from the stored row
|
||||
request_source: SendRequestSource::Supplied,
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: UpdateSource::Plugin,
|
||||
cookie_jar_id,
|
||||
|
||||
@@ -8,7 +8,10 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||
use tokio::sync::watch::Receiver;
|
||||
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak::send::{
|
||||
ResponseBody, SendHttpRequestWithPluginsParams, SendRequestSource,
|
||||
send_http_request_with_plugins,
|
||||
};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
||||
@@ -70,6 +73,7 @@ pub struct SentHttpRequest {
|
||||
pub async fn send_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
request_source: SendRequestSource,
|
||||
og_response: &HttpResponse,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
@@ -78,6 +82,7 @@ pub async fn send_http_request<R: Runtime>(
|
||||
send_http_request_with_context(
|
||||
window,
|
||||
unrendered_request,
|
||||
request_source,
|
||||
og_response,
|
||||
environment,
|
||||
cookie_jar,
|
||||
@@ -90,6 +95,7 @@ pub async fn send_http_request<R: Runtime>(
|
||||
pub async fn send_http_request_with_context<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
request_source: SendRequestSource,
|
||||
og_response: &HttpResponse,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
@@ -106,6 +112,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
||||
let result = send_http_request_inner(
|
||||
window,
|
||||
unrendered_request,
|
||||
request_source,
|
||||
environment,
|
||||
cookie_jar,
|
||||
cancelled_rx,
|
||||
@@ -141,6 +148,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
||||
async fn send_http_request_inner<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
request_source: SendRequestSource,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancelled_rx: &Receiver<bool>,
|
||||
@@ -159,6 +167,7 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
query_manager: app_handle.db_manager().inner(),
|
||||
blob_manager: app_handle.blob_manager().inner(),
|
||||
request: unrendered_request.clone(),
|
||||
request_source,
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: response_ctx.update_source.clone(),
|
||||
cookie_jar_id,
|
||||
|
||||
@@ -28,7 +28,7 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak::send::{ResponseBody, SendRequestSource};
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_common::command::new_checked_command;
|
||||
@@ -40,6 +40,7 @@ use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
|
||||
HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
@@ -332,6 +333,10 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
|
||||
|
||||
let conn = app_handle.with_tx(|tx| {
|
||||
// Capture the stored request, not the rendered one: what a restore should
|
||||
// put back is what the user typed
|
||||
let version_id =
|
||||
tx.snapshot_request_for_send(&AnyRequest::GrpcRequest(unrendered_request.clone()));
|
||||
tx.upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
@@ -340,6 +345,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
elapsed: 0,
|
||||
state: GrpcConnectionState::Initialized,
|
||||
url: request.url.clone(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
@@ -967,7 +973,16 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
});
|
||||
|
||||
let sent =
|
||||
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx)
|
||||
// No id, so no version either way; it is not a stored row
|
||||
send_http_request(
|
||||
&window,
|
||||
&request,
|
||||
SendRequestSource::Supplied,
|
||||
&response,
|
||||
environment,
|
||||
cookie_jar,
|
||||
&mut cancel_rx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Blanking the request id above is what makes this send unsaved, so the
|
||||
@@ -1123,9 +1138,12 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
None => None,
|
||||
};
|
||||
|
||||
// `request` is the row loaded above, so the version is taken from it rather than
|
||||
// re-read once plugins have started, which an edit could beat
|
||||
let r = match send_http_request(
|
||||
&window,
|
||||
&request,
|
||||
SendRequestSource::Stored,
|
||||
&response,
|
||||
environment,
|
||||
cookie_jar,
|
||||
|
||||
@@ -19,6 +19,7 @@ use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::SendRequestSource;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_models::models::{HttpRequest, HttpResponse, Plugin};
|
||||
@@ -486,6 +487,8 @@ pub(crate) async fn send_http_request_in_window<R: Runtime>(
|
||||
send_http_request_with_context(
|
||||
window,
|
||||
&http_request,
|
||||
// A plugin may have rendered it, so it is versioned from the stored row
|
||||
SendRequestSource::Supplied,
|
||||
&http_response,
|
||||
environment,
|
||||
cookie_jar,
|
||||
|
||||
@@ -36,9 +36,9 @@ use yaak_grpc::ServiceDefinition;
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, ImportSource, ModelVersion, Plugin, RequestVersionComparison, Settings,
|
||||
WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
@@ -871,6 +871,27 @@ async fn models_duplicate<R: Runtime>(
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_snapshot_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsSnapshotRequestReq,
|
||||
) -> Result<ModelVersion> {
|
||||
Ok(yaak_commands::models::models_snapshot_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_request_version<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsRequestVersionReq,
|
||||
) -> Result<RequestVersionComparison> {
|
||||
Ok(yaak_commands::models::models_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_restore_request_version<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsRestoreRequestVersionReq,
|
||||
) -> Result<AnyModel> {
|
||||
Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
|
||||
@@ -21,6 +21,7 @@ use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
@@ -173,10 +174,15 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
.await?;
|
||||
|
||||
let connection = app_handle.with_tx(|tx| {
|
||||
// Capture the stored request, not the rendered one: what a restore should
|
||||
// put back is what the user typed
|
||||
let version_id =
|
||||
tx.snapshot_request_for_send(&AnyRequest::WebsocketRequest(unrendered_request.clone()));
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
|
||||
@@ -138,6 +138,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -242,6 +246,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -347,6 +355,28 @@ export type KeyValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ModelVersion = {
|
||||
model: "model_version";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
/**
|
||||
* The `model` field of the versioned model, eg. `http_request`.
|
||||
*/
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
contentHash: string;
|
||||
document: Record<string, any>;
|
||||
reason: ModelVersionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||
* phrase these — but it is what makes a history readable when debugging.
|
||||
*/
|
||||
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
@@ -374,6 +404,23 @@ export type ProxySetting =
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
/**
|
||||
* One version, next to the request as it stands now.
|
||||
*
|
||||
* Both halves come from the same place so they are guaranteed comparable: the
|
||||
* frontend renders them side by side, and `differs` is the same content-hash
|
||||
* comparison the backend uses everywhere else rather than a second opinion
|
||||
* formed in TypeScript.
|
||||
*/
|
||||
export type RequestVersionComparison = {
|
||||
version: ModelVersion;
|
||||
/**
|
||||
* The live request's editable content, in the same shape as the version's document.
|
||||
*/
|
||||
currentDocument: Record<string, any>;
|
||||
differs: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
@@ -439,6 +486,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
+8
-2
@@ -3,7 +3,7 @@ import type { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } f
|
||||
import type { CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose } from "./gen_events";
|
||||
import type { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult } from "./gen_git";
|
||||
import type { ServiceDefinition } from "./gen_grpc";
|
||||
import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models";
|
||||
import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin, RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models";
|
||||
import type { PluginMetadata } from "./gen_search";
|
||||
import type { SyncOp } from "./gen_sync";
|
||||
import type { BatchUpsertResult, ImportDestination, ImportPlan } from "./gen_util";
|
||||
@@ -261,6 +261,12 @@ export type ModelsGetSettingsReq = Record<string, never>;
|
||||
|
||||
export type ModelsGrpcEventsReq = { connectionId: string, };
|
||||
|
||||
export type ModelsRequestVersionReq = { versionId: string, };
|
||||
|
||||
export type ModelsRestoreRequestVersionReq = { versionId: string, };
|
||||
|
||||
export type ModelsSnapshotRequestReq = { requestId: string, reason: ModelVersionReason, };
|
||||
|
||||
export type ModelsUpsertGraphqlIntrospectionReq = { requestId: string, workspaceId: string, content: string | null, };
|
||||
|
||||
export type ModelsUpsertReq = { model: AnyModel, };
|
||||
@@ -269,6 +275,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
|
||||
|
||||
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
|
||||
|
||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_agent_setup_status: [CmdAgentSetupStatusReq, AgentSetupStatus], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatLine truncated
|
||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_agent_setup_status: [CmdAgentSetupStatusReq, AgentSetupStatus], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_snapshot_request: [ModelsSnapshotRequestReq, ModelVersion], models_request_version: [ModelsRequestVersionReq, RequestVersionComparison], models_restore_request_version: [ModelsRestoreRequestVersionReq, AnyModel], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResLine truncated
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
@@ -21,8 +21,8 @@ use yaak_git::{
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin,
|
||||
RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
@@ -568,6 +568,28 @@ pub struct ModelsDuplicateReq {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsSnapshotRequestReq {
|
||||
pub request_id: String,
|
||||
pub reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsRequestVersionReq {
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsRestoreRequestVersionReq {
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
@@ -1019,6 +1041,9 @@ macro_rules! with_commands {
|
||||
models_upsert(ModelsUpsertReq) -> String,
|
||||
models_delete(ModelsDeleteReq) -> String,
|
||||
models_duplicate(ModelsDuplicateReq) -> String,
|
||||
models_snapshot_request(ModelsSnapshotRequestReq) -> ModelVersion,
|
||||
models_request_version(ModelsRequestVersionReq) -> RequestVersionComparison,
|
||||
models_restore_request_version(ModelsRestoreRequestVersionReq) -> AnyModel,
|
||||
models_websocket_events(ModelsWebsocketEventsReq) -> Vec<WebsocketEvent>,
|
||||
models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>,
|
||||
models_get_settings(ModelsGetSettingsReq) -> Settings,
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, ModelVersion,
|
||||
RequestVersionComparison, Settings, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_models::versions::version_document;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn models_upsert<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
|
||||
@@ -46,6 +47,44 @@ pub async fn models_duplicate<H: Host>(host: H, req: ModelsDuplicateReq) -> Resu
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Capture the request's current content, from an edit-session boundary the
|
||||
/// frontend can see: switching away, losing focus, closing, or falling idle.
|
||||
///
|
||||
/// The frontend does not track whether anything actually changed — versions are
|
||||
/// content-addressed, so an unchanged request returns the version it already
|
||||
/// had and the trigger code stays a one-liner.
|
||||
pub async fn models_snapshot_request<H: Host>(
|
||||
host: H,
|
||||
req: ModelsSnapshotRequestReq,
|
||||
) -> Result<ModelVersion> {
|
||||
Ok(host.query_manager().with_tx(|tx| tx.snapshot_request_by_id(&req.request_id, req.reason))?)
|
||||
}
|
||||
|
||||
/// A version and the live request side by side, for the diff and for deciding
|
||||
/// whether there is anything worth offering.
|
||||
pub async fn models_request_version<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRequestVersionReq,
|
||||
) -> Result<RequestVersionComparison> {
|
||||
let db = host.db();
|
||||
let version = db.get_model_version(&req.version_id)?;
|
||||
let current_document = version_document(&db.get_any_request(&version.model_id)?.to_value()?)?;
|
||||
let differs = !db.request_matches_version(&version)?;
|
||||
Ok(RequestVersionComparison { version, current_document, differs })
|
||||
}
|
||||
|
||||
/// Returns the restored request, so the frontend can put it in its store
|
||||
/// without waiting for the change event to come back around.
|
||||
pub async fn models_restore_request_version<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRestoreRequestVersionReq,
|
||||
) -> Result<AnyModel> {
|
||||
let source = host.update_source();
|
||||
let restored =
|
||||
host.query_manager().with_tx(|tx| tx.restore_request_version(&req.version_id, &source))?;
|
||||
Ok(restored.into())
|
||||
}
|
||||
|
||||
pub async fn models_websocket_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
|
||||
+51
@@ -139,6 +139,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -243,6 +247,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -388,6 +396,28 @@ export type ModelPayload = {
|
||||
change: ModelChangeEvent;
|
||||
};
|
||||
|
||||
export type ModelVersion = {
|
||||
model: "model_version";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
/**
|
||||
* The `model` field of the versioned model, eg. `http_request`.
|
||||
*/
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
contentHash: string;
|
||||
document: Record<string, any>;
|
||||
reason: ModelVersionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||
* phrase these — but it is what makes a history readable when debugging.
|
||||
*/
|
||||
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||
|
||||
export type ParentAuthentication = {
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
@@ -431,6 +461,23 @@ export type ProxySetting =
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
/**
|
||||
* One version, next to the request as it stands now.
|
||||
*
|
||||
* Both halves come from the same place so they are guaranteed comparable: the
|
||||
* frontend renders them side by side, and `differs` is the same content-hash
|
||||
* comparison the backend uses everywhere else rather than a second opinion
|
||||
* formed in TypeScript.
|
||||
*/
|
||||
export type RequestVersionComparison = {
|
||||
version: ModelVersion;
|
||||
/**
|
||||
* The live request's editable content, in the same shape as the version's document.
|
||||
*/
|
||||
currentDocument: Record<string, any>;
|
||||
differs: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
@@ -495,6 +542,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE model_versions
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'model_version' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
document TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Content addressing, enforced by the database rather than by every caller.
|
||||
CREATE UNIQUE INDEX model_versions_content ON model_versions (model_id, content_hash);
|
||||
|
||||
ALTER TABLE http_responses ADD COLUMN version_id TEXT;
|
||||
ALTER TABLE grpc_connections ADD COLUMN version_id TEXT;
|
||||
ALTER TABLE websocket_connections ADD COLUMN version_id TEXT;
|
||||
@@ -152,6 +152,20 @@ impl<'a> WriteDb<'a> {
|
||||
Ok(m.clone())
|
||||
}
|
||||
|
||||
/// Upsert a model WITHOUT recording a model change or emitting an event.
|
||||
///
|
||||
/// Only for rows that are nobody's business but this process's — model
|
||||
/// versions, whose whole point is that they are local history. Anything the
|
||||
/// frontend, sync or another window should learn about goes through
|
||||
/// [`Self::upsert`].
|
||||
pub(crate) fn upsert_untracked<M>(&self, model: &M) -> Result<M>
|
||||
where
|
||||
M: UpsertModelInfo + Clone,
|
||||
{
|
||||
let (m, _created) = self.ctx.upsert(model, &UpdateSource::Background.to_db())?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn record_model_change(&self, payload: &ModelPayload) -> Result<()> {
|
||||
let payload_json = serde_json::to_string(payload)?;
|
||||
let source_json = serde_json::to_string(&payload.update_source)?;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! What counts as a model's *content*, and how content becomes a hash.
|
||||
//!
|
||||
//! Several features need to answer "are these two models the same?" without
|
||||
//! being fooled by the fields that change every time a model is written at all:
|
||||
//! request versioning asks it to decide whether to capture a new version,
|
||||
//! import asks it to decide whether a re-import is a change or a conflict.
|
||||
//! They ask slightly different questions — see [`PLACEMENT_KEYS`] — so what is
|
||||
//! shared here is the mechanism and the reasoning, not one fixed answer.
|
||||
//!
|
||||
//! The implementation is lifted from the import merge work on
|
||||
//! `import-remember-selection` (#619), which got here first and got it right;
|
||||
//! that branch's private copy should become a call into this module when it
|
||||
//! lands.
|
||||
//!
|
||||
//! Not shared with directory sync, deliberately. Sync checksums the *bytes of a
|
||||
//! file* to notice that someone edited it on disk, so its hash has to reflect
|
||||
//! formatting and key order — exactly what this module throws away.
|
||||
|
||||
use crate::error::Result;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Fields that say which model this is and when it was last touched, rather
|
||||
/// than anything a user typed.
|
||||
///
|
||||
/// Every model carries them and every write rewrites at least `updatedAt`, so
|
||||
/// leaving them in would make every model differ from every copy of itself.
|
||||
/// `id` is not listed because it needs [`strip_ids`], which reaches nested rows
|
||||
/// too.
|
||||
pub const IDENTITY_KEYS: &[&str] = &["model", "workspaceId", "createdAt", "updatedAt"];
|
||||
|
||||
/// Fields that say where a model sits, rather than what it holds.
|
||||
///
|
||||
/// `sortPriority` is not content for anybody: importers number it from source
|
||||
/// order, so comparing it turns one insertion into an update of everything
|
||||
/// after it, and dragging a request up the sidebar is not an edit.
|
||||
///
|
||||
/// `folderId` is where the two callers actually part company, and it is a real
|
||||
/// disagreement rather than an oversight. Versioning drops it: moving a request
|
||||
/// into a folder is not an edit and must not mint a version. Import keeps it:
|
||||
/// equality there means "same content in the same place", so a source that
|
||||
/// moved a resource is showing you a change.
|
||||
pub const PLACEMENT_KEYS: &[&str] = &["folderId", "sortPriority"];
|
||||
|
||||
/// A model's JSON with the named top-level keys removed.
|
||||
pub fn without_keys(mut value: Value, keys: &[&str]) -> Value {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for key in keys {
|
||||
object.remove(*key);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Drop every `id`, at every depth.
|
||||
///
|
||||
/// A header, parameter, or variable carries an `id` that identifies its row to
|
||||
/// the editor rather than anything about its content, and the editor fills
|
||||
/// those in the first time it touches a resource. Dropping every `id` keeps
|
||||
/// that from reading as a change — otherwise merely opening a request would
|
||||
/// look like an edit of all of its headers at once.
|
||||
pub fn strip_ids(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
.into_iter()
|
||||
.filter(|(key, _)| key != "id")
|
||||
.map(|(key, value)| (key, strip_ids(value)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(items.into_iter().map(strip_ids).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix on every hash this module writes.
|
||||
///
|
||||
/// A hash written by a version a build doesn't understand says nothing about
|
||||
/// the content, and the caller needs to be able to tell that apart from a hash
|
||||
/// that says "different". Bump it whenever the stripping or the canonical form
|
||||
/// changes.
|
||||
pub const CONTENT_HASH_VERSION: &str = "v1:";
|
||||
|
||||
/// A stable hash of a document's content.
|
||||
pub fn content_hash(document: &Value) -> Result<String> {
|
||||
let canonical = serde_json::to_string(&sorted_keys(document.clone()))?;
|
||||
Ok(format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
/// Whether a stored hash was written by an algorithm this build understands.
|
||||
pub fn hash_is_readable(hash: &str) -> bool {
|
||||
hash.starts_with(CONTENT_HASH_VERSION)
|
||||
}
|
||||
|
||||
/// Rebuild every object with its keys in sorted order.
|
||||
///
|
||||
/// Serializing straight from the input would not do: whether
|
||||
/// `serde_json::Map` preserves insertion order or sorts is a workspace-wide
|
||||
/// feature decision — `preserve_order` is on in some builds of this workspace
|
||||
/// and off in others — and a document read back from SQLite has whatever order
|
||||
/// it was written in. Sorting first makes the hash depend on the content and
|
||||
/// nothing else, in every build.
|
||||
fn sorted_keys(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut entries = object.into_iter().collect::<Vec<_>>();
|
||||
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
Value::Object(entries.into_iter().map(|(k, v)| (k, sorted_keys(v))).collect())
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.into_iter().map(sorted_keys).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The hash has to survive a round trip through SQLite, which stores a
|
||||
/// document as text and hands back whatever order it was written in. It
|
||||
/// also has to survive `serde_json`'s `preserve_order` feature being on in
|
||||
/// one build of the workspace and off in another.
|
||||
#[test]
|
||||
fn key_order_does_not_change_the_hash() {
|
||||
let a: Value = serde_json::from_str(r#"{"url":"a","method":"GET"}"#).unwrap();
|
||||
let b: Value = serde_json::from_str(r#"{"method":"GET","url":"a"}"#).unwrap();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_order_does_not_change_the_hash_when_nested() {
|
||||
let a: Value = serde_json::from_str(
|
||||
r#"{"body":{"text":"x","type":"json"},"headers":[{"a":1,"b":2}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let b: Value = serde_json::from_str(
|
||||
r#"{"headers":[{"b":2,"a":1}],"body":{"type":"json","text":"x"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
/// Sorting keys must not make different documents collide.
|
||||
#[test]
|
||||
fn array_order_still_changes_the_hash() {
|
||||
let a: Value = serde_json::from_str(r#"{"headers":[{"n":"a"},{"n":"b"}]}"#).unwrap();
|
||||
let b: Value = serde_json::from_str(r#"{"headers":[{"n":"b"},{"n":"a"}]}"#).unwrap();
|
||||
assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_carry_a_readable_version() {
|
||||
let hash = content_hash(&json!({"url": "a"})).unwrap();
|
||||
assert!(hash_is_readable(&hash));
|
||||
assert!(!hash_is_readable("v99:deadbeef"));
|
||||
assert!(!hash_is_readable("deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_keys_leaves_everything_else_alone() {
|
||||
let stripped = without_keys(json!({"model": "http_request", "url": "a"}), IDENTITY_KEYS);
|
||||
let object = stripped.as_object().unwrap();
|
||||
assert!(!object.contains_key("model"));
|
||||
assert_eq!(object.get("url").unwrap(), "a");
|
||||
}
|
||||
|
||||
/// The editor writes row ids into headers and parameters the first time it
|
||||
/// touches a request, so nested ids have to go or that reads as an edit.
|
||||
#[test]
|
||||
fn strip_ids_reaches_nested_rows() {
|
||||
let with_ids = json!({
|
||||
"id": "rq_1",
|
||||
"url": "a",
|
||||
"headers": [{"id": "h_1", "name": "Accept", "value": "*/*"}],
|
||||
});
|
||||
let without = json!({
|
||||
"url": "a",
|
||||
"headers": [{"name": "Accept", "value": "*/*"}],
|
||||
});
|
||||
assert_eq!(strip_ids(with_ids.clone()), strip_ids(without.clone()));
|
||||
assert_eq!(
|
||||
content_hash(&strip_ids(with_ids)).unwrap(),
|
||||
content_hash(&strip_ids(without)).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use yaak_database::SqlitePool;
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
mod connection_or_tx;
|
||||
pub mod content;
|
||||
pub mod cookies;
|
||||
pub mod error;
|
||||
pub mod example;
|
||||
@@ -23,6 +24,7 @@ pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
pub mod util;
|
||||
pub mod versions;
|
||||
|
||||
/// Per-connection setup, applied by every pool on every connection it opens.
|
||||
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
|
||||
@@ -1543,6 +1543,8 @@ pub struct WebsocketConnection {
|
||||
pub state: WebsocketConnectionState,
|
||||
pub status: i32,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for WebsocketConnection {
|
||||
@@ -1582,6 +1584,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
(State, serde_json::to_value(&self.state)?.as_str().into()),
|
||||
(Status, self.status.into()),
|
||||
(Url, self.url.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1594,6 +1597,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
WebsocketConnectionIden::State,
|
||||
WebsocketConnectionIden::Status,
|
||||
WebsocketConnectionIden::Url,
|
||||
WebsocketConnectionIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1616,6 +1620,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
error: row.get("error")?,
|
||||
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
||||
status: row.get("status")?,
|
||||
version_id: row.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1969,6 +1974,8 @@ pub struct HttpResponse {
|
||||
pub state: HttpResponseState,
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
/// The request version this response was sent from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for HttpResponse {
|
||||
@@ -2018,6 +2025,7 @@ impl UpsertModelInfo for HttpResponse {
|
||||
(Url, self.url.into()),
|
||||
(Version, self.version.into()),
|
||||
(RequestContentLength, self.request_content_length.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2040,6 +2048,7 @@ impl UpsertModelInfo for HttpResponse {
|
||||
HttpResponseIden::StatusReason,
|
||||
HttpResponseIden::Url,
|
||||
HttpResponseIden::Version,
|
||||
HttpResponseIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2075,6 +2084,7 @@ impl UpsertModelInfo for HttpResponse {
|
||||
r.get::<_, String>("request_headers").unwrap_or_default().as_str(),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
version_id: r.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2520,6 +2530,8 @@ pub struct GrpcConnection {
|
||||
pub state: GrpcConnectionState,
|
||||
pub trailers: BTreeMap<String, String>,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for GrpcConnection {
|
||||
@@ -2561,6 +2573,7 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
(Error, self.error.as_ref().map(|s| s.as_str()).into()),
|
||||
(Trailers, serde_json::to_string(&self.trailers)?.into()),
|
||||
(Url, self.url.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2575,6 +2588,7 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
GrpcConnectionIden::Error,
|
||||
GrpcConnectionIden::Trailers,
|
||||
GrpcConnectionIden::Url,
|
||||
GrpcConnectionIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2599,6 +2613,7 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
url: row.get("url")?,
|
||||
error: row.get("error")?,
|
||||
trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(),
|
||||
version_id: row.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3147,6 +3162,154 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a version was captured. Not a UI label — the frontend decides how to
|
||||
/// phrase these — but it is what makes a history readable when debugging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum ModelVersionReason {
|
||||
Send,
|
||||
Switch,
|
||||
Idle,
|
||||
Restore,
|
||||
/// Reserved: an explicit "save a version now" action, which has no UI yet.
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Default for ModelVersionReason {
|
||||
fn default() -> Self {
|
||||
Self::Manual
|
||||
}
|
||||
}
|
||||
|
||||
/// A point-in-time copy of one request's editable content.
|
||||
///
|
||||
/// Versions are content-addressed: `content_hash` covers exactly what
|
||||
/// `document` holds, and `(model_id, content_hash)` is unique, so capturing the
|
||||
/// same content twice returns the row that already exists. That is what lets
|
||||
/// every send snapshot unconditionally without growing the table.
|
||||
///
|
||||
/// Deliberately absent from [`AnyModel`]: versions are local history. They are
|
||||
/// not synced, not exported, and not mirrored into the frontend's model store —
|
||||
/// the frontend asks for the one version it needs to show.
|
||||
impl Default for ModelVersion {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "model_version".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
model_type: String::new(),
|
||||
model_id: String::new(),
|
||||
content_hash: String::new(),
|
||||
document: Value::Object(Default::default()),
|
||||
reason: ModelVersionReason::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "model_versions")]
|
||||
pub struct ModelVersion {
|
||||
#[ts(type = "\"model_version\"")]
|
||||
pub model: String,
|
||||
pub id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub workspace_id: String,
|
||||
|
||||
/// The `model` field of the versioned model, eg. `http_request`.
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub content_hash: String,
|
||||
#[ts(type = "Record<string, any>")]
|
||||
pub document: Value,
|
||||
pub reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for ModelVersion {
|
||||
fn table_name() -> impl IntoTableRef + IntoIden {
|
||||
ModelVersionIden::Table
|
||||
}
|
||||
|
||||
fn id_column() -> impl IntoIden + Eq + Clone {
|
||||
ModelVersionIden::Id
|
||||
}
|
||||
|
||||
fn generate_id() -> String {
|
||||
generate_prefixed_id("mv")
|
||||
}
|
||||
|
||||
fn order_by() -> (impl IntoColumnRef, Order) {
|
||||
(ModelVersionIden::CreatedAt, Desc)
|
||||
}
|
||||
|
||||
fn get_id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn insert_values(
|
||||
self,
|
||||
source: &UpdateSource,
|
||||
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
||||
use ModelVersionIden::*;
|
||||
Ok(vec![
|
||||
(CreatedAt, upsert_date(source, self.created_at)),
|
||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||
(WorkspaceId, self.workspace_id.into()),
|
||||
(ModelType, self.model_type.into()),
|
||||
(ModelId, self.model_id.into()),
|
||||
(ContentHash, self.content_hash.into()),
|
||||
(Document, serde_json::to_string(&self.document)?.into()),
|
||||
(Reason, serde_json::to_value(self.reason)?.as_str().into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn update_columns() -> Vec<impl IntoIden> {
|
||||
vec![ModelVersionIden::UpdatedAt]
|
||||
}
|
||||
|
||||
fn from_row(row: &Row) -> rusqlite::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let document: String = row.get("document")?;
|
||||
let reason: String = row.get("reason")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
workspace_id: row.get("workspace_id")?,
|
||||
model_type: row.get("model_type")?,
|
||||
model_id: row.get("model_id")?,
|
||||
content_hash: row.get("content_hash")?,
|
||||
document: serde_json::from_str(&document).unwrap_or_default(),
|
||||
reason: serde_json::from_str(format!(r#""{reason}""#).as_str()).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One version, next to the request as it stands now.
|
||||
///
|
||||
/// Both halves come from the same place so they are guaranteed comparable: the
|
||||
/// frontend renders them side by side, and `differs` is the same content-hash
|
||||
/// comparison the backend uses everywhere else rather than a second opinion
|
||||
/// formed in TypeScript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct RequestVersionComparison {
|
||||
pub version: ModelVersion,
|
||||
/// The live request's editable content, in the same shape as the version's document.
|
||||
#[ts(type = "Record<string, any>")]
|
||||
pub current_document: Value,
|
||||
pub differs: bool,
|
||||
}
|
||||
|
||||
/// Only used as a `from_row` fallback for an unparseable settings column. The
|
||||
/// value a *new* model gets comes from that model's `Default` impl.
|
||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
use crate::models::{AnyModel, GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
use serde_json::Value;
|
||||
|
||||
pub enum AnyRequest {
|
||||
HttpRequest(HttpRequest),
|
||||
@@ -8,6 +9,42 @@ pub enum AnyRequest {
|
||||
WebsocketRequest(WebsocketRequest),
|
||||
}
|
||||
|
||||
/// Run an expression against whichever request this is, bound as `$request`.
|
||||
macro_rules! with_request {
|
||||
($self:expr, |$request:ident| $body:expr) => {
|
||||
match $self {
|
||||
AnyRequest::HttpRequest($request) => $body,
|
||||
AnyRequest::GrpcRequest($request) => $body,
|
||||
AnyRequest::WebsocketRequest($request) => $body,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl AnyRequest {
|
||||
pub fn id(&self) -> &str {
|
||||
with_request!(self, |request| &request.id)
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
with_request!(self, |request| &request.workspace_id)
|
||||
}
|
||||
|
||||
/// The model name, eg. `http_request`.
|
||||
pub fn model_type(&self) -> &str {
|
||||
with_request!(self, |request| &request.model)
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> Result<Value> {
|
||||
Ok(with_request!(self, |request| serde_json::to_value(request)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AnyRequest> for AnyModel {
|
||||
fn from(request: AnyRequest) -> Self {
|
||||
with_request!(request, |request| request.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
|
||||
if let Ok(http_request) = self.get_http_request(id) {
|
||||
|
||||
@@ -110,6 +110,7 @@ impl<'a> WriteDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(m.id.as_str())?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
@@ -145,6 +146,8 @@ impl<'a> WriteDb<'a> {
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.upsert(grpc_request, source)
|
||||
let request = self.upsert(grpc_request, source)?;
|
||||
self.move_model_versions_to_workspace(&request.id, &request.workspace_id)?;
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,7 @@ impl<'a> WriteDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(m.id.as_str())?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
@@ -177,7 +178,9 @@ impl<'a> WriteDb<'a> {
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.upsert(http_request, source)
|
||||
let request = self.upsert(http_request, source)?;
|
||||
self.move_model_versions_to_workspace(&request.id, &request.workspace_id)?;
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ mod import_source_resources;
|
||||
mod import_sources;
|
||||
mod key_values;
|
||||
mod model_changes;
|
||||
mod model_versions;
|
||||
mod plugin_key_values;
|
||||
mod plugins;
|
||||
mod settings;
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::content::content_hash;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models::{
|
||||
GrpcRequest, HttpRequest, ModelVersion, ModelVersionIden, ModelVersionReason, UpsertModelInfo,
|
||||
WebsocketRequest,
|
||||
};
|
||||
use crate::queries::any_request::AnyRequest;
|
||||
use crate::util::UpdateSource;
|
||||
use crate::versions::{apply_version_document, version_document};
|
||||
use log::warn;
|
||||
use sea_query::{Expr, ExprTrait, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
/// Unreferenced versions older than this are dropped.
|
||||
const RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// How many unreferenced versions a request keeps, newest first.
|
||||
const RETENTION_COUNT: i64 = 50;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_model_version(&self, id: &str) -> Result<ModelVersion> {
|
||||
self.find_one(ModelVersionIden::Id, id)
|
||||
}
|
||||
|
||||
/// Every version of one model, newest first.
|
||||
pub fn list_model_versions(&self, model_id: &str) -> Result<Vec<ModelVersion>> {
|
||||
self.find_many(ModelVersionIden::ModelId, model_id, None)
|
||||
}
|
||||
|
||||
/// Whether a request's content has moved on from a given version.
|
||||
pub fn request_matches_version(&self, version: &ModelVersion) -> Result<bool> {
|
||||
let live = self.get_any_request(&version.model_id)?;
|
||||
let hash = content_hash(&version_document(&live.to_value()?)?)?;
|
||||
Ok(hash == version.content_hash)
|
||||
}
|
||||
|
||||
fn find_version_by_hash(&self, model_id: &str, content_hash: &str) -> Option<ModelVersion> {
|
||||
let (sql, params) = Query::select()
|
||||
.from(ModelVersionIden::Table)
|
||||
.column(sea_query::Asterisk)
|
||||
.cond_where(
|
||||
Expr::col(ModelVersionIden::ModelId)
|
||||
.eq(model_id)
|
||||
.and(Expr::col(ModelVersionIden::ContentHash).eq(content_hash)),
|
||||
)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = self.conn().prepare(sql.as_str()).ok()?;
|
||||
stmt.query_row(&*params.as_params(), ModelVersion::from_row).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// Capture a request's current content, or return the version that already
|
||||
/// holds it.
|
||||
///
|
||||
/// The single entry point for creating versions. Callers do not check
|
||||
/// whether anything changed first — that is what content addressing is for,
|
||||
/// and it is why a send, a window blur and an idle timer can all call this
|
||||
/// on the same unedited request and leave one row behind.
|
||||
pub fn snapshot_request(
|
||||
&self,
|
||||
request: &AnyRequest,
|
||||
reason: ModelVersionReason,
|
||||
) -> Result<ModelVersion> {
|
||||
let document = version_document(&request.to_value()?)?;
|
||||
let content_hash = content_hash(&document)?;
|
||||
|
||||
if let Some(existing) = self.find_version_by_hash(request.id(), &content_hash) {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let version = self.upsert_untracked(&ModelVersion {
|
||||
workspace_id: request.workspace_id().to_string(),
|
||||
model_type: request.model_type().to_string(),
|
||||
model_id: request.id().to_string(),
|
||||
content_hash: content_hash.clone(),
|
||||
document,
|
||||
reason,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let version = match version {
|
||||
Ok(version) => version,
|
||||
// Two sends of the same request can both miss the lookup above and
|
||||
// race to insert. The unique index settles it, and the loser wants
|
||||
// exactly what the winner wrote.
|
||||
Err(err) => match self.find_version_by_hash(request.id(), &content_hash) {
|
||||
Some(existing) => return Ok(existing),
|
||||
None => return Err(err),
|
||||
},
|
||||
};
|
||||
|
||||
self.prune_model_versions(request.id())?;
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
pub fn snapshot_request_by_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
reason: ModelVersionReason,
|
||||
) -> Result<ModelVersion> {
|
||||
self.snapshot_request(&self.get_any_request(request_id)?, reason)
|
||||
}
|
||||
|
||||
/// What a send calls when all it has is the request's id: capture the
|
||||
/// stored row, and don't make a fuss.
|
||||
///
|
||||
/// For sends whose request may not be the stored one: a plugin can send a
|
||||
/// request it has already rendered, and a version of that would hold
|
||||
/// literal values, secrets included, where the user wrote templates. The
|
||||
/// stored row is what a restore should put back. A request with no id or
|
||||
/// no stored row is ephemeral and has nothing to version.
|
||||
pub fn snapshot_stored_request_for_send(&self, request_id: &str) -> Option<String> {
|
||||
if request_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.get_any_request(request_id) {
|
||||
Ok(stored) => self.snapshot_request_for_send(&stored),
|
||||
Err(Error::ModelNotFound(_)) => None,
|
||||
Err(err) => {
|
||||
warn!("Failed to load request to snapshot before send: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a send calls with the stored request it loaded: capture it, and
|
||||
/// don't make a fuss.
|
||||
///
|
||||
/// The caller must pass the request as stored, never a resolved or rendered
|
||||
/// copy; with only an id, use [`Self::snapshot_stored_request_for_send`].
|
||||
/// A send is not worth failing over history that couldn't be written, and
|
||||
/// a request with no id is ephemeral and has nothing to version. Either way
|
||||
/// the response just has no version to offer.
|
||||
pub fn snapshot_request_for_send(&self, request: &AnyRequest) -> Option<String> {
|
||||
if request.id().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.snapshot_request(request, ModelVersionReason::Send) {
|
||||
Ok(version) => Some(version.id),
|
||||
Err(err) => {
|
||||
warn!("Failed to snapshot request before send: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a version's content back over the live request.
|
||||
///
|
||||
/// Anything the live request has picked up since its last version is
|
||||
/// captured first, so a restore is never the thing that loses an edit. The
|
||||
/// content being written already has a version — the one being restored —
|
||||
/// so this leaves no new row behind.
|
||||
pub fn restore_request_version(
|
||||
&self,
|
||||
version_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<AnyRequest> {
|
||||
let version = self.get_model_version(version_id)?;
|
||||
let live = self.get_any_request(&version.model_id)?;
|
||||
self.snapshot_request(&live, ModelVersionReason::Restore)?;
|
||||
|
||||
let restored = apply_version_document(&live.to_value()?, &version.document);
|
||||
Ok(match live {
|
||||
AnyRequest::HttpRequest(_) => {
|
||||
AnyRequest::HttpRequest(self.upsert_http_request(
|
||||
&serde_json::from_value::<HttpRequest>(restored)?,
|
||||
source,
|
||||
)?)
|
||||
}
|
||||
AnyRequest::GrpcRequest(_) => {
|
||||
AnyRequest::GrpcRequest(self.upsert_grpc_request(
|
||||
&serde_json::from_value::<GrpcRequest>(restored)?,
|
||||
source,
|
||||
)?)
|
||||
}
|
||||
AnyRequest::WebsocketRequest(_) => {
|
||||
AnyRequest::WebsocketRequest(self.upsert_websocket_request(
|
||||
&serde_json::from_value::<WebsocketRequest>(restored)?,
|
||||
source,
|
||||
)?)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_model_versions_for_model(&self, model_id: &str) -> Result<usize> {
|
||||
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::ModelId, model_id)
|
||||
}
|
||||
|
||||
/// Keep a request's versions in the workspace the request is in.
|
||||
///
|
||||
/// A workspace delete removes its versions by workspace, so a request moved
|
||||
/// to another workspace must take its history with it, or deleting the old
|
||||
/// workspace would delete versions that responses in the new one point at.
|
||||
/// Called on every request write; when nothing moved it changes no rows.
|
||||
pub(crate) fn move_model_versions_to_workspace(
|
||||
&self,
|
||||
model_id: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<usize> {
|
||||
Ok(self.conn().execute(
|
||||
"UPDATE model_versions SET workspace_id = ?2 WHERE model_id = ?1 AND workspace_id != ?2",
|
||||
rusqlite::params![model_id, workspace_id],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Drop the versions a request no longer needs.
|
||||
///
|
||||
/// A version referenced by a response outlives retention entirely — the
|
||||
/// point of the feature is that an old response can still show what sent
|
||||
/// it. Everything else is history the user has not asked to keep, and
|
||||
/// survives only while it is both recent and among the newest few.
|
||||
pub fn prune_model_versions(&self, model_id: &str) -> Result<usize> {
|
||||
let cutoff = format!("-{RETENTION_DAYS} days");
|
||||
let sql = r#"
|
||||
DELETE FROM model_versions
|
||||
WHERE model_id = ?1
|
||||
AND id NOT IN (
|
||||
SELECT version_id FROM http_responses WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
UNION
|
||||
SELECT version_id FROM grpc_connections WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
UNION
|
||||
SELECT version_id FROM websocket_connections WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
)
|
||||
AND (
|
||||
created_at < datetime('now', ?2)
|
||||
OR id NOT IN (
|
||||
SELECT id FROM model_versions WHERE model_id = ?1
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?3
|
||||
)
|
||||
)
|
||||
"#;
|
||||
Ok(self.conn().execute(sql, rusqlite::params![model_id, cutoff, RETENTION_COUNT])?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
|
||||
fn source() -> UpdateSource {
|
||||
UpdateSource::Background
|
||||
}
|
||||
|
||||
/// Run a test inside one write transaction on a fresh database. Reads
|
||||
/// inside it see the transaction's own writes.
|
||||
fn with_db(test: impl FnOnce(&WriteDb, &BlobManager)) {
|
||||
let (query_manager, blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
test(db, &blobs);
|
||||
Ok::<_, crate::error::Error>(())
|
||||
})
|
||||
.expect("Failed to run test transaction");
|
||||
}
|
||||
|
||||
fn seed(db: &WriteDb) -> (Workspace, HttpRequest) {
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Versions".to_string(), ..Default::default() },
|
||||
&source(),
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Original".to_string(),
|
||||
url: "https://example.com/one".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
(workspace, request)
|
||||
}
|
||||
|
||||
fn snapshot(db: &WriteDb, request_id: &str, reason: ModelVersionReason) -> ModelVersion {
|
||||
db.snapshot_request_by_id(request_id, reason).expect("Failed to snapshot")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshotting_unchanged_content_reuses_the_same_version() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let first = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
let second = snapshot(db, &request.id, ModelVersionReason::Idle);
|
||||
let third = snapshot(db, &request.id, ModelVersionReason::Switch);
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
assert_eq!(first.id, third.id);
|
||||
// The first capture's reason is the one that sticks; a version is its content
|
||||
assert_eq!(second.reason, ModelVersionReason::Send);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_writes_do_not_mint_a_version() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let first = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
|
||||
let folder = db
|
||||
.upsert_folder(
|
||||
&crate::models::Folder {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
folder_id: Some(folder.id),
|
||||
sort_priority: 42.0,
|
||||
..db.get_http_request(&request.id).unwrap()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snapshot(db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
/// The pair editor writes a generated `id` into every header row the first
|
||||
/// time it touches a request, and that write reaches the database like any
|
||||
/// other. Without nested id stripping, merely opening a request would mint
|
||||
/// a version whose diff is nothing but ids.
|
||||
#[test]
|
||||
fn row_ids_written_by_the_editor_do_not_mint_a_version() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let header = |id: Option<&str>| crate::models::HttpRequestHeader {
|
||||
name: "Accept".to_string(),
|
||||
value: "application/json".to_string(),
|
||||
id: id.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { headers: vec![header(None)], ..request },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let first = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
|
||||
// Opening the request in the editor fills the row id in
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { headers: vec![header(Some("row_generated"))], ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snapshot(db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
|
||||
// A real edit to the same row still counts
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
headers: vec![crate::models::HttpRequestHeader {
|
||||
value: "text/plain".to_string(),
|
||||
..header(Some("row_generated"))
|
||||
}],
|
||||
..request.clone()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(snapshot(db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_content_mints_a_version() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot(db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_writes_the_old_content_back_without_a_new_version() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let original = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
url: "https://example.com/two".to_string(),
|
||||
name: "Edited".to_string(),
|
||||
..request.clone()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let edited = snapshot(db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
db.restore_request_version(&original.id, &source()).expect("Failed to restore");
|
||||
|
||||
let live = db.get_http_request(&request.id).unwrap();
|
||||
assert_eq!(live.url, "https://example.com/one");
|
||||
assert_eq!(live.name, "Original");
|
||||
assert_eq!(live.id, request.id);
|
||||
|
||||
// The restored content already had a version, and the edit it replaced
|
||||
// still has its own, so nothing new appears
|
||||
let versions = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert!(versions.iter().any(|v| v.id == original.id));
|
||||
assert!(versions.iter().any(|v| v.id == edited.id));
|
||||
});
|
||||
}
|
||||
|
||||
/// The case restore exists to be safe for: an edit that was never captured.
|
||||
#[test]
|
||||
fn restoring_captures_uncaptured_edits_first() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let original = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/unsaved".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.restore_request_version(&original.id, &source()).expect("Failed to restore");
|
||||
|
||||
let versions = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
let rescued = versions.iter().find(|v| v.id != original.id).unwrap();
|
||||
assert_eq!(rescued.reason, ModelVersionReason::Restore);
|
||||
assert_eq!(rescued.document.get("url").unwrap(), "https://example.com/unsaved");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_matches_version_tracks_the_live_content() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let version = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
assert!(db.request_matches_version(&version).unwrap());
|
||||
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!db.request_matches_version(&version).unwrap());
|
||||
});
|
||||
}
|
||||
|
||||
/// Write `count` distinct versions by walking the request's URL forward.
|
||||
fn make_versions(db: &WriteDb, request: &HttpRequest, count: usize) -> Vec<ModelVersion> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: format!("https://example.com/{i}"), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot(db, &request.id, ModelVersionReason::Idle)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_versions_are_pruned_to_the_newest_fifty() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let versions = make_versions(db, &request, RETENTION_COUNT as usize + 10);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(kept.len(), RETENTION_COUNT as usize);
|
||||
// The oldest went first
|
||||
assert!(!kept.iter().any(|v| v.id == versions[0].id));
|
||||
assert!(kept.iter().any(|v| v.id == versions.last().unwrap().id));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_referenced_version_survives_retention() {
|
||||
with_db(|db, blobs| {
|
||||
let (workspace, request) = seed(db);
|
||||
|
||||
let pinned = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
version_id: Some(pinned.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
blobs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
make_versions(db, &request, RETENTION_COUNT as usize + 10);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert!(
|
||||
kept.iter().any(|v| v.id == pinned.id),
|
||||
"a version a response points at must outlive retention",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_versions_expire_after_thirty_days() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
let old = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
db.conn()
|
||||
.execute(
|
||||
"UPDATE model_versions SET created_at = datetime('now', '-31 days') WHERE id = ?1",
|
||||
rusqlite::params![old.id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Any later capture prunes
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let fresh = snapshot(db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].id, fresh.id);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_request_deletes_its_versions() {
|
||||
with_db(|db, _blobs| {
|
||||
let (_workspace, request) = seed(db);
|
||||
|
||||
make_versions(db, &request, 3);
|
||||
assert!(!db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
|
||||
db.delete_http_request_by_id(&request.id, &source()).unwrap();
|
||||
assert!(db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_workspace_deletes_its_requests_versions() {
|
||||
with_db(|db, blobs| {
|
||||
let (workspace, request) = seed(db);
|
||||
make_versions(db, &request, 2);
|
||||
|
||||
db.delete_workspace(&workspace, &source(), blobs).unwrap();
|
||||
assert!(db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
/// A moved request takes its history along: deleting the workspace it
|
||||
/// left must not delete versions its responses in the new one point at.
|
||||
#[test]
|
||||
fn a_moved_request_keeps_its_versions_when_its_old_workspace_is_deleted() {
|
||||
with_db(|db, blobs| {
|
||||
let (old_workspace, request) = seed(db);
|
||||
let version = snapshot(db, &request.id, ModelVersionReason::Send);
|
||||
|
||||
let new_workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Elsewhere".to_string(), ..Default::default() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let moved = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { workspace_id: new_workspace.id.clone(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Moving changes no content, so a send from the new workspace
|
||||
// reuses the version, and a response there points at it
|
||||
let sent = snapshot(db, &moved.id, ModelVersionReason::Send);
|
||||
assert_eq!(sent.id, version.id);
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: moved.id.clone(),
|
||||
workspace_id: new_workspace.id.clone(),
|
||||
version_id: Some(version.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
blobs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.delete_workspace(&old_workspace, &source(), blobs).unwrap();
|
||||
|
||||
let survived = db.get_model_version(&version.id).expect("the version must survive");
|
||||
assert_eq!(survived.workspace_id, new_workspace.id);
|
||||
assert!(db.request_matches_version(&survived).unwrap());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ impl<'a> WriteDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(websocket_request.id.as_str())?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
@@ -167,6 +168,8 @@ impl<'a> WriteDb<'a> {
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.upsert(websocket_request, source)
|
||||
let request = self.upsert(websocket_request, source)?;
|
||||
self.move_model_versions_to_workspace(&request.id, &request.workspace_id)?;
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ use crate::models::{
|
||||
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
|
||||
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
|
||||
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
||||
WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden,
|
||||
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta,
|
||||
WorkspaceMetaIden,
|
||||
ImportSourceIden, ModelVersion, ModelVersionIden, ResolvedHttpRequestSettings, ResolvedSetting,
|
||||
SyncState, SyncStateIden, WebsocketConnection, WebsocketConnectionIden, WebsocketEvent,
|
||||
WebsocketEventIden, WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden,
|
||||
WorkspaceMeta, WorkspaceMetaIden,
|
||||
};
|
||||
use crate::util::UpdateSource;
|
||||
use log::warn;
|
||||
@@ -123,6 +123,7 @@ impl<'a> WriteDb<'a> {
|
||||
self.delete_import_source_resources(&import_source.id)?;
|
||||
}
|
||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
let deleted = self.delete(workspace, source)?;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Request versioning's answer to "what is this request's content?"
|
||||
//!
|
||||
//! The mechanism lives in [`crate::content`], shared with import. What is
|
||||
//! decided here is versioning's own policy: placement is not content, and a
|
||||
//! restore lays a document back over the model it came from.
|
||||
|
||||
use crate::content::{IDENTITY_KEYS, PLACEMENT_KEYS, strip_ids, without_keys};
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// The editable content of a model, as the object a version stores.
|
||||
///
|
||||
/// Dropping [`PLACEMENT_KEYS`] as well as [`IDENTITY_KEYS`] is what makes a
|
||||
/// version stable: moving a request into a folder, dragging it up the sidebar,
|
||||
/// or simply saving it again rewrite those and nothing else, and none of them
|
||||
/// should mint a version or show up in a diff. `strip_ids` does the same job
|
||||
/// for the row ids the editor writes into headers and parameters — without it,
|
||||
/// opening a request would mint a version whose diff is nothing but ids.
|
||||
///
|
||||
/// One rule covers HTTP, gRPC and WebSocket, because the three differ only in
|
||||
/// the content fields, which are all kept.
|
||||
pub fn version_document<T: Serialize>(model: &T) -> Result<Value> {
|
||||
let stripped = [IDENTITY_KEYS, PLACEMENT_KEYS].concat();
|
||||
Ok(without_keys(strip_ids(serde_json::to_value(model)?), &stripped))
|
||||
}
|
||||
|
||||
/// Lay a version's document back over a live model.
|
||||
///
|
||||
/// Keys the document carries win; keys it doesn't mention keep whatever the
|
||||
/// live model has. That covers both halves of a restore: bookkeeping (id,
|
||||
/// folder, sort order) survives because the document never held it, and a field
|
||||
/// added to the model after the version was captured survives because the
|
||||
/// version predates it.
|
||||
pub fn apply_version_document(live: &Value, document: &Value) -> Value {
|
||||
let mut merged = live.as_object().cloned().unwrap_or_else(Map::new);
|
||||
if let Some(document) = document.as_object() {
|
||||
for (key, value) in document {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(merged)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content::content_hash;
|
||||
use crate::models::{HttpRequest, HttpRequestHeader};
|
||||
use chrono::Utc;
|
||||
|
||||
fn request() -> HttpRequest {
|
||||
HttpRequest {
|
||||
id: "rq_1".to_string(),
|
||||
workspace_id: "wk_1".to_string(),
|
||||
folder_id: Some("fl_1".to_string()),
|
||||
name: "Get user".to_string(),
|
||||
url: "https://example.com/users/1".to_string(),
|
||||
method: "GET".to_string(),
|
||||
sort_priority: 1.0,
|
||||
headers: vec![HttpRequestHeader {
|
||||
name: "Accept".to_string(),
|
||||
value: "application/json".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_of(request: &HttpRequest) -> String {
|
||||
content_hash(&version_document(request).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_holds_content_and_drops_bookkeeping() {
|
||||
let document = version_document(&request()).unwrap();
|
||||
let object = document.as_object().unwrap();
|
||||
|
||||
for key in [IDENTITY_KEYS, PLACEMENT_KEYS].concat() {
|
||||
assert!(!object.contains_key(key), "document should not carry {key}");
|
||||
}
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/users/1");
|
||||
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||
assert_eq!(object.get("method").unwrap(), "GET");
|
||||
assert!(object.contains_key("headers"));
|
||||
assert!(object.contains_key("body"));
|
||||
assert!(object.contains_key("authentication"));
|
||||
assert!(object.contains_key("description"));
|
||||
assert!(object.contains_key("settingFollowRedirects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_never_changes_the_hash() {
|
||||
let base = hash_of(&request());
|
||||
|
||||
let moved = HttpRequest { folder_id: Some("fl_2".to_string()), ..request() };
|
||||
assert_eq!(hash_of(&moved), base, "folder");
|
||||
|
||||
let resorted = HttpRequest { sort_priority: 99.5, ..request() };
|
||||
assert_eq!(hash_of(&resorted), base, "sort priority");
|
||||
|
||||
let touched = HttpRequest {
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
created_at: Utc::now().naive_utc(),
|
||||
..request()
|
||||
};
|
||||
assert_eq!(hash_of(&touched), base, "timestamps");
|
||||
|
||||
let renamed_id = HttpRequest { id: "rq_2".to_string(), ..request() };
|
||||
assert_eq!(hash_of(&renamed_id), base, "id");
|
||||
|
||||
let moved_workspace = HttpRequest { workspace_id: "wk_2".to_string(), ..request() };
|
||||
assert_eq!(hash_of(&moved_workspace), base, "workspace");
|
||||
}
|
||||
|
||||
/// The other half of the split documented on [`PLACEMENT_KEYS`]. Import
|
||||
/// counts a move between folders as a change; versioning must not, or
|
||||
/// dragging a request around the sidebar would mint versions nobody asked
|
||||
/// for.
|
||||
#[test]
|
||||
fn placement_is_not_content_here_even_though_import_says_it_is() {
|
||||
let base = version_document(&request()).unwrap();
|
||||
let moved =
|
||||
version_document(&HttpRequest { folder_id: Some("fl_2".into()), ..request() }).unwrap();
|
||||
let resorted = version_document(&HttpRequest { sort_priority: 99.5, ..request() }).unwrap();
|
||||
|
||||
assert_eq!(moved, base);
|
||||
assert_eq!(resorted, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_content_changes_the_hash() {
|
||||
let base = hash_of(&request());
|
||||
|
||||
assert_ne!(
|
||||
hash_of(&HttpRequest { url: "https://example.com/users/2".into(), ..request() }),
|
||||
base
|
||||
);
|
||||
assert_ne!(hash_of(&HttpRequest { method: "POST".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { name: "Get other user".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { description: "Notes".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { headers: vec![], ..request() }), base);
|
||||
assert_ne!(
|
||||
hash_of(&HttpRequest { body_type: Some("application/json".into()), ..request() }),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_document_keeps_the_live_model_identity() {
|
||||
let live = serde_json::to_value(request()).unwrap();
|
||||
let document = version_document(&HttpRequest {
|
||||
url: "https://example.com/users/2".to_string(),
|
||||
..request()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let merged = apply_version_document(&live, &document);
|
||||
let object = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/users/2");
|
||||
assert_eq!(object.get("id").unwrap(), "rq_1");
|
||||
assert_eq!(object.get("folderId").unwrap(), "fl_1");
|
||||
assert_eq!(object.get("sortPriority").unwrap(), 1.0);
|
||||
assert_eq!(object.get("model").unwrap(), "http_request");
|
||||
}
|
||||
|
||||
/// A version captured before a field existed must not blank that field out.
|
||||
#[test]
|
||||
fn applying_an_older_document_leaves_unknown_fields_alone() {
|
||||
let live = serde_json::to_value(request()).unwrap();
|
||||
let document = serde_json::json!({ "url": "https://example.com/old" });
|
||||
|
||||
let merged = apply_version_document(&live, &document);
|
||||
let object = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/old");
|
||||
assert_eq!(object.get("method").unwrap(), "GET");
|
||||
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||
}
|
||||
}
|
||||
+12
@@ -138,6 +138,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -242,6 +246,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -431,6 +439,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
@@ -32,12 +32,14 @@ use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::cookies::apply_cookie_changes;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
HttpSendSettings, ModelVersionReason, RequestVersionComparison,
|
||||
};
|
||||
use yaak_models::models_ops;
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_models::versions::version_document;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback};
|
||||
|
||||
/// Names inside the VFS, not paths on any disk. Two files because the desktop
|
||||
@@ -223,6 +225,19 @@ struct UpsertIntrospectionReq {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SnapshotRequestReq {
|
||||
request_id: String,
|
||||
reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VersionIdReq {
|
||||
version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExportDataReq {
|
||||
@@ -339,6 +354,35 @@ fn dispatch(
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_snapshot_request" => {
|
||||
let req: SnapshotRequestReq = from_js(payload)?;
|
||||
to_json(
|
||||
host.queries
|
||||
.with_tx(|tx| tx.snapshot_request_by_id(&req.request_id, req.reason))
|
||||
.map_err(js_error)?,
|
||||
)
|
||||
}
|
||||
|
||||
"models_request_version" => {
|
||||
let req: VersionIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let version = db.get_model_version(&req.version_id).map_err(js_error)?;
|
||||
let request = db.get_any_request(&version.model_id).map_err(js_error)?;
|
||||
let current_document =
|
||||
version_document(&request.to_value().map_err(js_error)?).map_err(js_error)?;
|
||||
let differs = !db.request_matches_version(&version).map_err(js_error)?;
|
||||
to_json(RequestVersionComparison { version, current_document, differs })
|
||||
}
|
||||
|
||||
"models_restore_request_version" => {
|
||||
let req: VersionIdReq = from_js(payload)?;
|
||||
let restored = host
|
||||
.queries
|
||||
.with_tx(|tx| tx.restore_request_version(&req.version_id, source))
|
||||
.map_err(js_error)?;
|
||||
to_json(AnyModel::from(restored))
|
||||
}
|
||||
|
||||
"models_get_settings" => to_json(host.queries.connect().get_settings()),
|
||||
|
||||
"models_get_graphql_introspection" => {
|
||||
@@ -499,6 +543,9 @@ struct PreparedHttpSend {
|
||||
setting_events: Vec<HttpResponseEventData>,
|
||||
/// The jar the send starts with, so the tab can write it back with the proxy's changes.
|
||||
cookie_jar: Option<CookieJar>,
|
||||
/// The version of the stored row this send was prepared from, taken from the same
|
||||
/// read, so the response names exactly what was sent. `None` if it couldn't be written.
|
||||
version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A template callback for a host with no plugins. Variables render; a function is a clear
|
||||
@@ -541,9 +588,19 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
|
||||
let req: PrepareHttpSendReq = from_js(payload)?;
|
||||
|
||||
// Everything from the database first, then release the host borrow before rendering.
|
||||
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
|
||||
let (request, environment_chain, settings, cookie_jar, version_id) = with_host(|host| {
|
||||
let db = host.queries.connect();
|
||||
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
|
||||
// Version this read of the row, the one being sent, rather than reading it again
|
||||
// where an edit could land in between. Failing to write history fails nothing.
|
||||
let version_id = host
|
||||
.queries
|
||||
.with_tx(|tx| {
|
||||
Ok::<_, yaak_models::error::Error>(
|
||||
tx.snapshot_request_for_send(&AnyRequest::HttpRequest(request.clone())),
|
||||
)
|
||||
})
|
||||
.unwrap_or(None);
|
||||
let environment_chain = db
|
||||
.resolve_environments(
|
||||
&request.workspace_id,
|
||||
@@ -560,7 +617,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
|
||||
None => None,
|
||||
};
|
||||
let request = HttpRequest { authentication_type, authentication, headers, ..request };
|
||||
Ok((request, environment_chain, settings, cookie_jar))
|
||||
Ok((request, environment_chain, settings, cookie_jar, version_id))
|
||||
})?;
|
||||
|
||||
let rendered = render_http_request(
|
||||
@@ -591,6 +648,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
|
||||
settings: HttpSendSettings::from(&settings),
|
||||
setting_events: settings.timeline_events(),
|
||||
cookie_jar,
|
||||
version_id,
|
||||
};
|
||||
// JSON-compatible, as `rpc` does: the tab posts this to the proxy with `JSON.stringify`,
|
||||
// and the default serializer's `Map` for the request body would stringify to `{}`.
|
||||
|
||||
+292
-1
@@ -27,6 +27,7 @@ use yaak_models::models::{
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
|
||||
ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{UpdateSource, generate_prefixed_id};
|
||||
@@ -282,6 +283,9 @@ pub struct HttpSendInputs {
|
||||
/// Cookies the send starts with. The store is shared, so reading it back after the send
|
||||
/// returns (or fails) yields the cookies the transaction collected.
|
||||
pub cookie_store: Option<CookieStore>,
|
||||
/// The version holding the request's content as it was when this send was resolved,
|
||||
/// which the response will point at. `None` for an ephemeral request with no id.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a send writes its response. Without it, the send keeps everything in memory: no
|
||||
@@ -309,6 +313,9 @@ pub struct SendHttpRequestWithPluginsParams<'a> {
|
||||
pub query_manager: &'a QueryManager,
|
||||
pub blob_manager: &'a BlobManager,
|
||||
pub request: HttpRequest,
|
||||
/// Whether `request` is the stored row as loaded, which decides what its version is
|
||||
/// taken from; see [`SendRequestSource`].
|
||||
pub request_source: SendRequestSource,
|
||||
pub environment_id: Option<&'a str>,
|
||||
pub update_source: UpdateSource,
|
||||
pub cookie_jar_id: Option<String>,
|
||||
@@ -401,10 +408,25 @@ impl HttpSendRuntimeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the request a send was handed came from, which decides what its version is
|
||||
/// taken from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SendRequestSource {
|
||||
/// The stored row, exactly as this send loaded it. The version is taken from this
|
||||
/// copy, with no second read, so the response names what was actually sent even if
|
||||
/// the row is edited before the send resolves.
|
||||
Stored,
|
||||
/// Something that may differ from the stored row, such as a request a plugin
|
||||
/// rendered before sending. The version is read from the stored row by id instead,
|
||||
/// so rendered values (secrets included) never reach history.
|
||||
Supplied,
|
||||
}
|
||||
|
||||
/// Resolve every database-backed input a send needs, in one pass.
|
||||
pub fn resolve_send_inputs(
|
||||
query_manager: &QueryManager,
|
||||
request: &HttpRequest,
|
||||
request_source: SendRequestSource,
|
||||
environment_id: Option<&str>,
|
||||
cookies: Option<Vec<Cookie>>,
|
||||
) -> Result<HttpSendInputs> {
|
||||
@@ -433,9 +455,40 @@ pub fn resolve_send_inputs(
|
||||
client_certificates: settings.client_certificates,
|
||||
},
|
||||
cookie_store: cookies.map(CookieStore::from_cookies),
|
||||
// Every host reaches sending through this function — the desktop, the CLI,
|
||||
// plugin-triggered sends — so every response gets a version without each of them
|
||||
// remembering to ask for one. It is always of stored content, never of a resolved
|
||||
// or rendered copy, which is not what a restore should put back.
|
||||
version_id: snapshot_request_for_send(query_manager, request, request_source),
|
||||
})
|
||||
}
|
||||
|
||||
/// Capture the request in its own transaction. Like the snapshot itself, a failure to
|
||||
/// get the write connection is not worth failing the send over.
|
||||
fn snapshot_request_for_send(
|
||||
query_manager: &QueryManager,
|
||||
request: &HttpRequest,
|
||||
source: SendRequestSource,
|
||||
) -> Option<String> {
|
||||
// An unsaved request has nothing to version; don't take the writer for it
|
||||
if request.id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
query_manager
|
||||
.with_tx(|tx| {
|
||||
Ok::<_, yaak_models::error::Error>(match source {
|
||||
SendRequestSource::Stored => {
|
||||
tx.snapshot_request_for_send(&AnyRequest::HttpRequest(request.clone()))
|
||||
}
|
||||
SendRequestSource::Supplied => tx.snapshot_stored_request_for_send(&request.id),
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("Failed to snapshot request before send: {err}");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the authentication and headers a request inherits from its folder or workspace.
|
||||
pub fn resolve_inherited_request(
|
||||
query_manager: &QueryManager,
|
||||
@@ -468,6 +521,7 @@ pub async fn send_http_request_by_id_with_plugins(
|
||||
query_manager: params.query_manager,
|
||||
blob_manager: params.blob_manager,
|
||||
request,
|
||||
request_source: SendRequestSource::Stored,
|
||||
environment_id: params.environment_id,
|
||||
update_source: params.update_source,
|
||||
cookie_jar_id: params.cookie_jar_id,
|
||||
@@ -491,6 +545,7 @@ pub async fn send_http_request_with_plugins(
|
||||
let inputs = resolve_send_inputs(
|
||||
params.query_manager,
|
||||
¶ms.request,
|
||||
params.request_source,
|
||||
params.environment_id,
|
||||
cookie_jar.as_ref().map(|jar| jar.cookies.clone()),
|
||||
)?;
|
||||
@@ -549,6 +604,7 @@ pub async fn send_http_request_by_id<T: TemplateCallback>(
|
||||
let inputs = resolve_send_inputs(
|
||||
params.query_manager,
|
||||
&request,
|
||||
SendRequestSource::Stored,
|
||||
params.environment_id,
|
||||
cookie_jar.as_ref().map(|jar| jar.cookies.clone()),
|
||||
)?;
|
||||
@@ -580,7 +636,8 @@ pub async fn send_http_request_by_id<T: TemplateCallback>(
|
||||
pub async fn send_http_request<T: TemplateCallback>(
|
||||
params: SendHttpRequestParams<'_, T>,
|
||||
) -> Result<SendHttpRequestResult> {
|
||||
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store } = params.inputs;
|
||||
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store, version_id } =
|
||||
params.inputs;
|
||||
let (request, auth_context_id) = request.into_parts();
|
||||
let storage = params.storage;
|
||||
let send_options = runtime_config.send_options();
|
||||
@@ -618,6 +675,7 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
let mut response = params.existing_response.unwrap_or_default();
|
||||
response.request_id = request.id.clone();
|
||||
response.workspace_id = request.workspace_id.clone();
|
||||
response.version_id = version_id;
|
||||
response.request_content_length = request_content_length;
|
||||
response.request_headers = sendable_request
|
||||
.headers
|
||||
@@ -1332,6 +1390,7 @@ mod tests {
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: Some(CookieStore::new()),
|
||||
version_id: None,
|
||||
},
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: None,
|
||||
@@ -1401,6 +1460,7 @@ mod tests {
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: Some(CookieStore::new()),
|
||||
version_id: None,
|
||||
},
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: None,
|
||||
@@ -1455,6 +1515,237 @@ mod tests {
|
||||
(query_manager, cookie_jar, temp_dir)
|
||||
}
|
||||
|
||||
/// The whole point of the feature, end to end: a stored send must leave a
|
||||
/// response that can name the request behind it, and repeated sends of an
|
||||
/// unchanged request must all name the same one.
|
||||
#[tokio::test]
|
||||
async fn a_stored_send_links_the_request_version_that_produced_it() {
|
||||
let (query_manager, blob_manager, temp_dir) = seed_send_storage();
|
||||
let request = query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
url: "http://localhost/test".to_string(),
|
||||
name: "Original".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed request");
|
||||
|
||||
let send = || {
|
||||
stored_send(
|
||||
&query_manager,
|
||||
&blob_manager,
|
||||
temp_dir.path(),
|
||||
&request,
|
||||
SendRequestSource::Stored,
|
||||
)
|
||||
};
|
||||
let first = send().await;
|
||||
let second = send().await;
|
||||
|
||||
let version_id = first.version_id.clone().expect("a stored send must record a version");
|
||||
assert_eq!(
|
||||
second.version_id,
|
||||
Some(version_id.clone()),
|
||||
"an unchanged request is one version"
|
||||
);
|
||||
|
||||
let db = query_manager.connect();
|
||||
let version = db.get_model_version(&version_id).expect("Failed to load version");
|
||||
assert_eq!(version.model_id, request.id);
|
||||
assert_eq!(version.document.get("url").unwrap(), "http://localhost/test");
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
|
||||
// Editing after the fact is what the response pane has to be able to notice
|
||||
query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest { name: "Edited".to_string(), ..request.clone() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to edit request");
|
||||
assert!(!db.request_matches_version(&version).expect("Failed to compare"));
|
||||
}
|
||||
|
||||
/// A plugin can send a request it has already rendered. The version must
|
||||
/// still be of the stored row, templates and all, or a restore would write
|
||||
/// literal values (secrets included) over what the user typed.
|
||||
#[tokio::test]
|
||||
async fn a_send_of_a_rendered_request_versions_the_stored_templates() {
|
||||
let (query_manager, blob_manager, temp_dir) = seed_send_storage();
|
||||
let header = |value: &str| yaak_models::models::HttpRequestHeader {
|
||||
name: "Authorization".to_string(),
|
||||
value: value.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let stored = query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
url: "${[ base_url ]}/test".to_string(),
|
||||
headers: vec![header("Bearer ${[ token ]}")],
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed request");
|
||||
|
||||
let rendered = HttpRequest {
|
||||
url: "http://localhost/test".to_string(),
|
||||
headers: vec![header("Bearer s3cret")],
|
||||
..stored.clone()
|
||||
};
|
||||
let response = stored_send(
|
||||
&query_manager,
|
||||
&blob_manager,
|
||||
temp_dir.path(),
|
||||
&rendered,
|
||||
SendRequestSource::Supplied,
|
||||
)
|
||||
.await;
|
||||
|
||||
let version_id = response.version_id.expect("a stored request must record a version");
|
||||
let version =
|
||||
query_manager.connect().get_model_version(&version_id).expect("Failed to load version");
|
||||
assert_eq!(version.document.get("url").unwrap(), "${[ base_url ]}/test");
|
||||
let headers = version.document.get("headers").unwrap().to_string();
|
||||
assert!(headers.contains("Bearer ${[ token ]}"), "headers were {headers}");
|
||||
assert!(!headers.contains("s3cret"), "a rendered secret reached version history");
|
||||
assert!(
|
||||
query_manager.connect().request_matches_version(&version).unwrap(),
|
||||
"an untouched request must not look changed",
|
||||
);
|
||||
}
|
||||
|
||||
/// An edit can land between a send loading its request and resolving it (the
|
||||
/// desktop awaits plugin startup in between). The response must still name the
|
||||
/// content that was sent, not the edit.
|
||||
#[tokio::test]
|
||||
async fn an_edit_between_loading_and_sending_is_not_what_gets_versioned() {
|
||||
let (query_manager, blob_manager, temp_dir) = seed_send_storage();
|
||||
let stored = query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
url: "http://localhost/sent".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed request");
|
||||
|
||||
// The send has loaded `stored`; now the row changes underneath it
|
||||
let loaded = query_manager.connect().get_http_request(&stored.id).unwrap();
|
||||
query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest { url: "http://localhost/edited".to_string(), ..loaded.clone() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to edit request");
|
||||
|
||||
let response = stored_send(
|
||||
&query_manager,
|
||||
&blob_manager,
|
||||
temp_dir.path(),
|
||||
&loaded,
|
||||
SendRequestSource::Stored,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.url, "http://localhost/sent");
|
||||
let version_id = response.version_id.expect("a stored send must record a version");
|
||||
let version =
|
||||
query_manager.connect().get_model_version(&version_id).expect("Failed to load version");
|
||||
assert_eq!(version.document.get("url").unwrap(), "http://localhost/sent");
|
||||
// And the live request, which now holds the edit, shows as changed
|
||||
assert!(!query_manager.connect().request_matches_version(&version).unwrap());
|
||||
}
|
||||
|
||||
/// A request with an id but no stored row has nothing to version, and
|
||||
/// resolving its send must not fail for it.
|
||||
#[test]
|
||||
fn resolving_a_send_of_an_unstored_request_records_no_version() {
|
||||
let (query_manager, _blob_manager, _temp_dir) = seed_send_storage();
|
||||
let transient = HttpRequest {
|
||||
id: "rq_not_stored".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
url: "http://localhost/test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let inputs = resolve_send_inputs(
|
||||
&query_manager,
|
||||
&transient,
|
||||
SendRequestSource::Supplied,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to resolve send inputs");
|
||||
|
||||
assert_eq!(inputs.version_id, None);
|
||||
assert!(query_manager.connect().list_model_versions("rq_not_stored").unwrap().is_empty());
|
||||
}
|
||||
|
||||
async fn stored_send(
|
||||
query_manager: &QueryManager,
|
||||
blob_manager: &BlobManager,
|
||||
response_dir: &std::path::Path,
|
||||
request: &HttpRequest,
|
||||
source: SendRequestSource,
|
||||
) -> HttpResponse {
|
||||
let executor = StubExecutor { body: b"hello world" };
|
||||
let inputs = resolve_send_inputs(query_manager, request, source, None, None)
|
||||
.expect("Failed to resolve send inputs");
|
||||
send_http_request(SendHttpRequestParams {
|
||||
inputs,
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: Some(ResponseStorage {
|
||||
query_manager,
|
||||
blob_manager,
|
||||
update_source: UpdateSource::Sync,
|
||||
response_dir,
|
||||
}),
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
cancelled_rx: None,
|
||||
existing_response: None,
|
||||
prepare_sendable_request: None,
|
||||
executor: &executor,
|
||||
})
|
||||
.await
|
||||
.expect("send should succeed")
|
||||
.response
|
||||
}
|
||||
|
||||
fn seed_send_storage() -> (QueryManager, BlobManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&temp_dir.path().join("db.sqlite"),
|
||||
&temp_dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.expect("Failed to initialize DB");
|
||||
query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed workspace");
|
||||
(query_manager, blob_manager, temp_dir)
|
||||
}
|
||||
|
||||
fn cookie(name: &str) -> Cookie {
|
||||
Cookie {
|
||||
name: name.to_string(),
|
||||
|
||||
@@ -63,6 +63,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
db.rpc("models_get_graphql_introspection", payload),
|
||||
models_upsert_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_upsert_graphql_introspection", payload),
|
||||
models_snapshot_request: (payload, db) => db.rpc("models_snapshot_request", payload),
|
||||
models_request_version: (payload, db) => db.rpc("models_request_version", payload),
|
||||
models_restore_request_version: (payload, db) =>
|
||||
db.rpc("models_restore_request_version", payload),
|
||||
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
|
||||
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
|
||||
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
|
||||
|
||||
@@ -54,6 +54,8 @@ interface PreparedHttpSend {
|
||||
settings: HttpSendSettings;
|
||||
settingEvents: HttpResponseEventData[];
|
||||
cookieJar: CookieJar | null;
|
||||
/** The version of the stored request this send was prepared from, if one was written. */
|
||||
versionId: string | null;
|
||||
}
|
||||
|
||||
/** The desktop writes progress at most this often while a body streams in. */
|
||||
@@ -101,7 +103,9 @@ async function runSend(
|
||||
environmentId,
|
||||
cookieJarId,
|
||||
});
|
||||
await response.patch({ url: prepared.request.url });
|
||||
// The version comes from the same read of the request that was prepared, so the
|
||||
// response can offer back exactly what it sent
|
||||
await response.patch({ url: prepared.request.url, versionId: prepared.versionId });
|
||||
|
||||
// The first line of the timeline says what did the sending and where. A
|
||||
// request through a proxy shows a different origin to the server than the
|
||||
@@ -315,8 +319,16 @@ class TimelineWriter {
|
||||
* yaak-models), so an edit made while the send was in flight survives rather
|
||||
* than being written over by the send's stale snapshot.
|
||||
*/
|
||||
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
|
||||
async function persistCookies(
|
||||
db: WorkerConnection,
|
||||
jar: CookieJar,
|
||||
cookies: Cookie[],
|
||||
): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", {
|
||||
cookieJarId: jar.id,
|
||||
before: jar.cookies,
|
||||
after: cookies,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,6 +138,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -242,6 +246,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -431,6 +439,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
Reference in new issue
Block a user