Compare commits

..
Author SHA1 Message Date
dependabot[bot] 833293030f Bump @faker-js/faker from 10.3.0 to 10.5.0
Bumps [@faker-js/faker](https://github.com/faker-js/faker) from 10.3.0 to 10.5.0.
- [Release notes](https://github.com/faker-js/faker/releases)
- [Changelog](https://github.com/faker-js/faker/blob/next/CHANGELOG.md)
- [Commits](https://github.com/faker-js/faker/compare/v10.3.0...v10.5.0)

---
updated-dependencies:
- dependency-name: "@faker-js/faker"
  dependency-version: 10.5.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 03:07:55 +00:00
36 changed files with 118 additions and 1823 deletions
-101
View File
@@ -1,101 +0,0 @@
# Request versioning (IntelliJ Local History style)
Working plan for `feat/request-versioning`. Tracks
[save-request-data-for-response-history](https://yaak.app/feedback/posts/save-request-data-for-response-history).
Selecting an old response should be able to show, and restore, the request that produced it.
## Model
One table, `model_versions`, versions every request type:
| column | meaning |
| --- | --- |
| `id`, `model`, `created_at`, `updated_at` | usual model columns |
| `workspace_id` | owning workspace |
| `model_type` | `http_request` / `grpc_request` / `websocket_request` |
| `model_id` | the request the version belongs to |
| `content_hash` | sha256 of the canonical document |
| `document` | JSON of the request's editable content |
| `reason` | `send` / `switch` / `idle` / `restore` / `manual` |
`http_responses`, `grpc_connections` and `websocket_connections` each gain a nullable
`version_id`.
A version's `document` is the model's JSON with bookkeeping keys removed — `model`, `id`,
`createdAt`, `updatedAt`, `workspaceId`, `folderId`, `sortPriority`. One rule, applied the same
way to all three request types; the hash is taken over exactly what the document holds, so moving
a request between folders or re-sorting it never mints a version.
`(model_id, content_hash)` is unique, so dedup is the database's job rather than a code path that
can be forgotten. Sending an unchanged request ten times leaves one version and ten responses
pointing at it.
## Snapshot
One primitive, `ClientDb::snapshot_request(request, reason)`: build the document, hash it, return
the existing row for that hash or insert a new one, then prune. Everything calls it.
- **Sends.** `resolve_send_inputs` (HTTP, every host — desktop, CLI, plugin-triggered) snapshots
before the response row is created, and the resulting id rides down to the response.
gRPC and WebSocket connect paths do the same at their own connection upserts.
- **Edit-session boundaries the frontend can see**, all through one RPC: switching to another
request, window blur, app close, and a 60s idle timer after the last edit.
Over-triggering is free, so the trigger code stays dumb.
## Restore
`restore_request_version(version_id)`:
1. Snapshot the live request (reason `restore`), so anything newer than its last version is kept.
2. Merge the version's document over the live model, keeping bookkeeping fields.
3. Upsert. The written content's hash already exists, so no new version row appears.
The frontend calls `wasUpdatedExternally` afterwards so open editors reload.
## Retention
An unreferenced version survives only while it is among the newest 50 for its request *and* newer
than 30 days. A version referenced by a response lives as long as that response. Deleting a
request deletes its versions. Versions are local history: not synced to the filesystem, not in
Git, not exported.
## UI (v1, HTTP)
When the selected response's version differs from the live request, the response header grows a
state-labelled dropdown ("Request Changed", following the GraphQL editor's pattern) with **View
Diff** and **Restore**. The diff reuses the Git dialog's `DiffViewer` over YAML renderings of the
two documents. No versions timeline panel in v1; gRPC and WebSocket are wired on the backend from
day one and their UI can follow.
## Status
- [x] Migration + `ModelVersion` model + bindings
- [x] Hashing / document extraction, with tests
- [x] Queries: snapshot, prune, restore, cascade
- [x] Send pipelines: HTTP, gRPC, WebSocket (plus the browser host's own)
- [x] RPC commands + web/wasm host
- [x] Frontend: snapshot triggers, dropdown, diff dialog, restore
## Deliberately not in v1
- **No versions timeline panel.** The only entry point is a response, which is
what the feedback asked for. A "browse all versions of this request" view is a
second feature on the same data and can land later without a schema change.
- **No gRPC or WebSocket UI.** Both record versions from day one, so the history
is accumulating; only the indicator is HTTP-only.
- **No `manual` trigger.** The reason exists so that adding a "Save version now"
action later is a UI change and not a migration.
- **Folders, environments and workspaces are not versioned.** The response
timeline already records what a send inherited from them.
## Notes for later
- The version's `document` is the model minus bookkeeping keys, so a restore
merges over the live model and a field added after a version was captured
keeps its live value rather than being blanked.
- `content_hash` sorts object keys before hashing. Relying on
`serde_json::Map` being a `BTreeMap` is not safe: `preserve_order` is on in
some builds of this workspace and off in others, which is exactly the bug the
`key_order_does_not_change_the_hash` test caught.
@@ -30,7 +30,6 @@ import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestVersionDropdown } from "./RequestVersionDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
@@ -264,14 +263,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : (
<span />
)}
<HStack space={1} className="justify-self-end shrink-0">
<RequestVersionDropdown response={activeResponse} />
<div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</HStack>
</div>
</div>
)}
</HStack>
@@ -1,79 +0,0 @@
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 { data: 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 });
}
@@ -1,26 +0,0 @@
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.
*/
export function useRequestVersion(versionId: string | null | undefined, requestId: string | null) {
const requests = useAtomValue(allRequestsAtom);
const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
return useQuery({
placeholderData: (prev) => prev,
queryKey: ["request_version", versionId, liveUpdatedAt],
enabled: versionId != null,
queryFn: () =>
rpc<RequestVersionComparison>("models_request_version", { versionId: versionId! }),
});
}
@@ -1,75 +0,0 @@
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("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([]);
});
});
@@ -1,57 +0,0 @@
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 timer
* and two assignments instead of a change-tracking system.
*/
export class EditSessionTracker {
private timer: ReturnType<typeof setTimeout> | null = null;
private idleRequestId: string | null = null;
private activeRequestId: string | null = null;
constructor(
private readonly snapshot: Snapshot,
private readonly idleMs: number = IDLE_MS,
) {}
/** A request was written. Restarts its idle countdown. */
noteEdit(requestId: string) {
if (this.timer != null) clearTimeout(this.timer);
this.idleRequestId = requestId;
this.timer = setTimeout(() => {
this.timer = null;
const requestId = this.idleRequestId;
if (requestId != null) 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.snapshot(left, "switch");
}
/** The window lost focus or is closing. */
noteBoundary() {
if (this.activeRequestId != null) this.snapshot(this.activeRequestId, "switch");
}
}
-50
View File
@@ -1,50 +0,0 @@
import { 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) {
// Edits reach the database on a debounce, so flush before asking for a
// version of what is in it
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());
}
@@ -1,35 +0,0 @@
import { flushAllModelWrites } from "@yaakapp-internal/models";
import type { 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 () => {
// 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 requestId = await rpc<string>("models_restore_request_version", {
versionId: version.id,
});
// The write came from this window, so the store's echo suppression would
// otherwise leave open editors showing what was there before
wasUpdatedExternally(requestId);
showToast({
id: "request-version-restored",
color: "success",
message: "Restored the request that produced this response",
});
})(),
);
}
-2
View File
@@ -8,7 +8,6 @@ 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";
@@ -37,7 +36,6 @@ initGit();
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
initRequestVersionSnapshots();
await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root");
-8
View File
@@ -40,7 +40,6 @@ 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,
@@ -331,12 +330,6 @@ async fn cmd_grpc_go<R: Runtime>(
let settings = app_handle.db().get_settings();
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
// Capture the stored request, not the rendered one: what a restore should
// put back is what the user typed
let version_id = app_handle
.db()
.snapshot_request_for_send(&AnyRequest::GrpcRequest(unrendered_request.clone()));
let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection {
workspace_id: request.workspace_id.clone(),
@@ -345,7 +338,6 @@ 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()),
+2 -14
View File
@@ -37,8 +37,8 @@ use yaak_grpc::ServiceDefinition;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::{
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, ImportSource, ModelVersion, Plugin, RequestVersionComparison, Settings,
WebsocketConnection, WebsocketEvent, WorkspaceMeta,
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
@@ -653,18 +653,6 @@ async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq
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<String> {
Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?)
}
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
}
@@ -20,7 +20,6 @@ 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;
@@ -170,17 +169,10 @@ pub async fn cmd_ws_connect<R: Runtime>(
)
.await?;
// Capture the stored request, not the rendered one: what a restore should
// put back is what the user typed
let version_id = app_handle
.db()
.snapshot_request_for_send(&AnyRequest::WebsocketRequest(unrendered_request.clone()));
let connection = app_handle.db().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()),
+11 -51
View File
@@ -138,10 +138,6 @@ 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";
@@ -246,10 +242,6 @@ 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 = {
@@ -339,6 +331,17 @@ export type ImportSource = {
lastImportedAt: string;
};
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
@@ -355,28 +358,6 @@ 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;
@@ -404,23 +385,6 @@ 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;
@@ -485,10 +449,6 @@ 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";
File diff suppressed because one or more lines are too long
+2 -27
View File
@@ -21,8 +21,8 @@ use yaak_git::{
use yaak_grpc::ServiceDefinition;
use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin,
RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
@@ -534,28 +534,6 @@ 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")]
@@ -1003,9 +981,6 @@ 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) -> String,
models_websocket_events(ModelsWebsocketEventsReq) -> Vec<WebsocketEvent>,
models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>,
models_get_settings(ModelsGetSettingsReq) -> Settings,
+2 -39
View File
@@ -4,10 +4,9 @@
use crate::error::Result;
use crate::host::{Host, PluginHost};
use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, ModelVersion,
RequestVersionComparison, Settings, WebsocketEvent, WorkspaceMeta,
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
WorkspaceMeta,
};
use yaak_models::versions::version_document;
use yaak_models::queries::workspaces::default_headers;
use yaak_rpc_schema::*;
@@ -46,42 +45,6 @@ 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.db().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 id of the request that was restored.
pub async fn models_restore_request_version<H: Host>(
host: H,
req: ModelsRestoreRequestVersionReq,
) -> Result<String> {
let source = host.update_source();
let restored = host.db().restore_request_version(&req.version_id, &source)?;
Ok(restored.id().to_string())
}
pub async fn models_websocket_events<H: Host>(
host: H,
req: ModelsWebsocketEventsReq,
-51
View File
@@ -139,10 +139,6 @@ 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";
@@ -247,10 +243,6 @@ 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 = {
@@ -390,28 +382,6 @@ 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;
@@ -455,23 +425,6 @@ 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;
@@ -535,10 +488,6 @@ 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";
@@ -1,20 +0,0 @@
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;
-14
View File
@@ -118,20 +118,6 @@ impl<'a> ClientDb<'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)?;
-1
View File
@@ -21,7 +21,6 @@ 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<()> {
-163
View File
@@ -1539,8 +1539,6 @@ 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 {
@@ -1580,7 +1578,6 @@ 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()),
])
}
@@ -1593,7 +1590,6 @@ impl UpsertModelInfo for WebsocketConnection {
WebsocketConnectionIden::State,
WebsocketConnectionIden::Status,
WebsocketConnectionIden::Url,
WebsocketConnectionIden::VersionId,
]
}
@@ -1616,7 +1612,6 @@ 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(),
})
}
}
@@ -1970,8 +1965,6 @@ 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 {
@@ -2021,7 +2014,6 @@ impl UpsertModelInfo for HttpResponse {
(Url, self.url.into()),
(Version, self.version.into()),
(RequestContentLength, self.request_content_length.into()),
(VersionId, self.version_id.into()),
])
}
@@ -2044,7 +2036,6 @@ impl UpsertModelInfo for HttpResponse {
HttpResponseIden::StatusReason,
HttpResponseIden::Url,
HttpResponseIden::Version,
HttpResponseIden::VersionId,
]
}
@@ -2080,7 +2071,6 @@ 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(),
})
}
}
@@ -2526,8 +2516,6 @@ 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 {
@@ -2569,7 +2557,6 @@ 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()),
])
}
@@ -2584,7 +2571,6 @@ impl UpsertModelInfo for GrpcConnection {
GrpcConnectionIden::Error,
GrpcConnectionIden::Trailers,
GrpcConnectionIden::Url,
GrpcConnectionIden::VersionId,
]
}
@@ -2609,7 +2595,6 @@ 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(),
})
}
}
@@ -3154,154 +3139,6 @@ 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,7 +1,6 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
use serde_json::Value;
pub enum AnyRequest {
HttpRequest(HttpRequest),
@@ -9,36 +8,6 @@ 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<'a> ClientDb<'a> {
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
if let Ok(http_request) = self.get_http_request(id) {
@@ -38,7 +38,6 @@ impl<'a> ClientDb<'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)
}
@@ -24,7 +24,6 @@ impl<'a> ClientDb<'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)
}
-1
View File
@@ -15,7 +15,6 @@ mod import_source_resources;
mod import_sources;
mod key_values;
mod model_changes;
mod model_versions;
mod plugin_key_values;
mod plugins;
mod settings;
@@ -1,455 +0,0 @@
use crate::client_db::ClientDb;
use crate::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, content_hash, 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)
}
/// 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 every send calls: capture the request, and don't make a fuss.
///
/// 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,
)?,
),
})
}
/// 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)
}
pub fn delete_model_versions_for_model(&self, model_id: &str) -> Result<usize> {
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::ModelId, model_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])?)
}
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client_db::ClientDb;
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpResponse, Workspace};
fn source() -> UpdateSource {
UpdateSource::Background
}
fn seed(db: &ClientDb) -> (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: &ClientDb, 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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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);
}
#[test]
fn editing_content_mints_a_version() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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: &ClientDb, 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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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() {
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
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());
}
}
@@ -40,7 +40,6 @@ impl<'a> ClientDb<'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)
}
+1 -2
View File
@@ -7,7 +7,7 @@ use crate::models::{
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
ImportSourceIden, ModelVersion, ModelVersionIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
WebsocketConnection,
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
@@ -90,7 +90,6 @@ impl<'a> ClientDb<'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)?;
self.delete(workspace, source)
-240
View File
@@ -1,240 +0,0 @@
//! Content addressing for model versions.
//!
//! A version's identity is its *content*, so the two functions here — what
//! counts as content, and how content becomes a hash — are the whole of it.
//! Everything else about versioning (when to capture, what to keep, how to
//! restore) is built on top and stays in `queries::model_versions`.
use crate::error::Result;
use serde::Serialize;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
/// Keys that describe a model's place in the workspace rather than what the
/// user typed into it.
///
/// Dropping them is what makes a version stable: moving a request into a
/// folder, dragging it up the sidebar, or simply saving it again all rewrite
/// these and nothing else, and none of them should mint a version or show up
/// in a diff. It is also why one rule covers HTTP, gRPC and WebSocket — the
/// three differ only in the content fields, which are all kept.
const BOOKKEEPING_KEYS: &[&str] =
&["model", "id", "createdAt", "updatedAt", "workspaceId", "folderId", "sortPriority"];
/// The editable content of a model, as the object a version stores.
pub fn version_document<T: Serialize>(model: &T) -> Result<Value> {
let mut value = serde_json::to_value(model)?;
if let Some(object) = value.as_object_mut() {
for key in BOOKKEEPING_KEYS {
object.remove(*key);
}
}
Ok(value)
}
/// The hash a version is addressed by.
pub fn content_hash(document: &Value) -> Result<String> {
let mut canonical = String::new();
write_canonical(document, &mut canonical);
Ok(hex::encode(Sha256::digest(canonical.as_bytes())))
}
/// Serialize with object keys in sorted order.
///
/// Plain `to_string` would not do: whether `serde_json::Map` preserves
/// insertion order or sorts is a workspace-wide feature decision, and a
/// document read back from SQLite has whatever order it was written in. Sorting
/// here makes the hash depend on the content and nothing else, in every build.
fn write_canonical(value: &Value, out: &mut String) {
match value {
Value::Object(map) => {
let mut keys = map.keys().collect::<Vec<_>>();
keys.sort_unstable();
out.push('{');
for (i, key) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(&Value::String(key.clone()), out);
out.push(':');
write_canonical(&map[key], out);
}
out.push('}');
}
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(item, out);
}
out.push(']');
}
scalar => out.push_str(&scalar.to_string()),
}
}
/// 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::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 BOOKKEEPING_KEYS {
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");
}
#[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
);
}
/// The hash has to survive a round trip through SQLite, which stores the
/// 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 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");
}
}
-25
View File
@@ -11,7 +11,6 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -138,10 +137,6 @@ 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";
@@ -246,10 +241,6 @@ 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 = {
@@ -327,18 +318,6 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
@@ -438,10 +417,6 @@ 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";
+1 -46
View File
@@ -32,13 +32,12 @@ 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, ModelVersionReason, RequestVersionComparison,
HttpSendSettings,
};
use yaak_models::models_ops;
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
@@ -219,19 +218,6 @@ 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 ResponseIdReq {
@@ -333,37 +319,6 @@ fn dispatch(
to_json(id)
}
"models_snapshot_request" => {
let req: SnapshotRequestReq = from_js(payload)?;
to_json(
host.queries
.connect()
.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
.connect()
.restore_request_version(&req.version_id, source)
.map_err(js_error)?;
to_json(restored.id().to_string())
}
"models_get_settings" => to_json(host.queries.connect().get_settings()),
"models_get_graphql_introspection" => {
+3 -104
View File
@@ -24,10 +24,9 @@ use yaak_http::types::{
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::{
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState,
ProxySetting, ProxySettingAuth, ResolvedHttpRequestSettings,
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};
@@ -284,9 +283,6 @@ 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
@@ -438,13 +434,6 @@ pub fn resolve_send_inputs(
client_certificates: settings.client_certificates,
},
cookie_store: cookies.map(CookieStore::from_cookies),
// Captured here rather than deeper in the send because this is the last place that
// still holds the *stored* request: further down it has been resolved against its
// folder and workspace and then rendered, and neither of those is what a restore
// should put back. 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.
version_id: db.snapshot_request_for_send(&AnyRequest::HttpRequest(request.clone())),
})
}
@@ -592,8 +581,7 @@ 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, version_id } =
params.inputs;
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store } = params.inputs;
let (request, auth_context_id) = request.into_parts();
let storage = params.storage;
let send_options = runtime_config.send_options();
@@ -631,7 +619,6 @@ 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
@@ -1358,7 +1345,6 @@ mod tests {
client_certificates: Vec::new(),
},
cookie_store: Some(CookieStore::new()),
version_id: None,
},
template_callback: &NoopTemplateCallback,
storage: None,
@@ -1428,7 +1414,6 @@ mod tests {
client_certificates: Vec::new(),
},
cookie_store: Some(CookieStore::new()),
version_id: None,
},
template_callback: &NoopTemplateCallback,
storage: None,
@@ -1481,92 +1466,6 @@ 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
.connect()
.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 first = stored_send(&query_manager, &blob_manager, temp_dir.path(), &request).await;
let second = stored_send(&query_manager, &blob_manager, temp_dir.path(), &request).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
.connect()
.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"));
}
async fn stored_send(
query_manager: &QueryManager,
blob_manager: &BlobManager,
response_dir: &std::path::Path,
request: &HttpRequest,
) -> HttpResponse {
let executor = StubExecutor { body: b"hello world" };
let inputs = resolve_send_inputs(query_manager, request, 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
.connect()
.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(),
+83 -15
View File
@@ -1424,6 +1424,22 @@
"node": ">=18"
}
},
"node_modules/@faker-js/faker": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.5.0.tgz",
"integrity": "sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@gilbarbara/deep-equal": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
@@ -3856,6 +3872,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -15883,27 +15965,13 @@
"name": "@yaak/faker",
"version": "1.1.1",
"dependencies": {
"@faker-js/faker": "^10.1.0"
"@faker-js/faker": "^10.5.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
},
"plugins-external/faker/node_modules/@faker-js/faker": {
"version": "10.3.0",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"plugins-external/httpsnippet": {
"name": "@yaak/httpsnippet",
"version": "1.0.3",
+5 -26
View File
@@ -63,10 +63,6 @@ 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),
@@ -80,12 +76,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(
db,
requestId,
str(payload, "environmentId"),
str(payload, "cookieJarId"),
);
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
},
/* -------------------------------- app ---------------------------------- */
@@ -271,16 +262,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_delete_connections: [
"WebSocket requests aren't available in the browser yet",
"websocket",
],
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
// Anything that needs files the page can't reach.
cmd_import_data: [
"Importing from a file needs a filesystem, which a browser tab has no",
"localFiles",
],
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
@@ -313,14 +298,8 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_template_tokens_to_string: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
+3 -41
View File
@@ -30,7 +30,6 @@ import type {
HttpResponse,
HttpResponseEventData,
HttpSendSettings,
ModelVersion,
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
@@ -72,13 +71,7 @@ export async function sendHttpRequest(
// a failure to render or to reach the server lands in the response pane as
// that response's error rather than as a toast that names no request.
const workspaceId = await workspaceIdOfRequest(db, requestId);
const versionId = await snapshotRequestVersion(db, requestId);
const response = new ResponseWriter(db, {
model: "http_response",
requestId,
workspaceId,
versionId,
});
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
await response.create();
const cancel = new AbortController();
@@ -95,29 +88,6 @@ export async function sendHttpRequest(
return response.current();
}
/**
* Capture what is about to be sent, so the response can offer it back later.
* The desktop does this inside its send pipeline; this host's pipeline is here,
* so this is where it goes. Versions are content-addressed, so repeated sends
* of an unchanged request all point at the same one.
*/
async function snapshotRequestVersion(
db: WorkerConnection,
requestId: string,
): Promise<string | undefined> {
try {
const version = await db.rpc<ModelVersion>("models_snapshot_request", {
requestId,
reason: "send",
});
return version.id;
} catch (err) {
// History is not worth failing a send over
console.warn("Failed to snapshot request version", err);
return undefined;
}
}
async function runSend(
db: WorkerConnection,
response: ResponseWriter,
@@ -345,16 +315,8 @@ 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 });
}
/**
+1 -1
View File
@@ -15,7 +15,7 @@
"test": "vp test --run tests"
},
"dependencies": {
"@faker-js/faker": "^10.1.0"
"@faker-js/faker": "^10.5.0"
},
"devDependencies": {
"@types/node": "^25.0.3",