snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
29
crates/yaak-models/Cargo.toml
Normal file
29
crates/yaak-models/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "yaak-models"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
yaak-database = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
nanoid = "0.4.0"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
urlencoding = "2.1.3"
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-templates = { path = "../yaak-templates", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.32" }
|
||||
3
crates/yaak-models/bindings/ModelChangeEvent.ts
generated
Normal file
3
crates/yaak-models/bindings/ModelChangeEvent.ts
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" };
|
||||
580
crates/yaak-models/bindings/gen_models.ts
generated
Normal file
580
crates/yaak-models/bindings/gen_models.ts
generated
Normal file
@@ -0,0 +1,580 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ModelChangeEvent } from "./ModelChangeEvent";
|
||||
|
||||
export type AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
| SyncState
|
||||
| WebsocketConnection
|
||||
| WebsocketEvent
|
||||
| WebsocketRequest
|
||||
| Workspace
|
||||
| WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = {
|
||||
host: string;
|
||||
port: number | null;
|
||||
crtFile: string | null;
|
||||
keyFile: string | null;
|
||||
pfxFile: string | null;
|
||||
passphrase: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = {
|
||||
model: "cookie_jar";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
cookies: Array<Cookie>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string };
|
||||
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
model: "graphql_introspection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnection = {
|
||||
model: "grpc_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
method: string;
|
||||
service: string;
|
||||
status: number;
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = {
|
||||
model: "grpc_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
eventType: GrpcEventType;
|
||||
metadata: { [key in string]?: string };
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
export type GrpcEventType =
|
||||
| "info"
|
||||
| "error"
|
||||
| "client_message"
|
||||
| "server_message"
|
||||
| "connection_start"
|
||||
| "connection_end";
|
||||
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
elapsedHeaders: number;
|
||||
elapsedDns: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
remoteAddr: string | null;
|
||||
requestContentLength: number | null;
|
||||
requestHeaders: Array<HttpResponseHeader>;
|
||||
status: number;
|
||||
statusReason: string | null;
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
model: "http_response_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
responseId: string;
|
||||
event: HttpResponseEventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
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 ImportSourceResource = {
|
||||
model: "import_source_resource";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
/**
|
||||
* `None` once the user has decided not to import this key
|
||||
*/
|
||||
modelId?: string;
|
||||
/**
|
||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
*/
|
||||
contentHash?: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = {
|
||||
model: "key_value";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ModelPayload = {
|
||||
model: AnyModel;
|
||||
updateSource: UpdateSource;
|
||||
change: ModelChangeEvent;
|
||||
};
|
||||
|
||||
export type ParentAuthentication = {
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
};
|
||||
|
||||
export type ParentHeaders = { headers: Array<HttpRequestHeader> };
|
||||
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
checkedAt: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
source: PluginSource;
|
||||
};
|
||||
|
||||
export type PluginKeyValue = {
|
||||
model: "plugin_key_value";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
pluginName: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type ProxySetting =
|
||||
| {
|
||||
type: "enabled";
|
||||
http: string;
|
||||
https: string;
|
||||
auth: ProxySettingAuth | null;
|
||||
bypass: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
| { type: "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appearance: string;
|
||||
clientCertificates: Array<ClientCertificate>;
|
||||
coloredMethods: boolean;
|
||||
editorFont: string | null;
|
||||
editorFontSize: number;
|
||||
editorKeymap: EditorKeymap;
|
||||
editorSoftWrap: boolean;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceFont: string | null;
|
||||
interfaceFontSize: number;
|
||||
interfaceScale: number;
|
||||
openWorkspaceNewWindow: boolean | null;
|
||||
proxy: ProxySetting | null;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
hotkeys: { [key in string]?: Array<string> };
|
||||
};
|
||||
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type UpdateSource =
|
||||
| { type: "background" }
|
||||
| { type: "import" }
|
||||
| { type: "plugin" }
|
||||
| { type: "sync" }
|
||||
| { type: "window"; label: string };
|
||||
|
||||
export type WebsocketConnection = {
|
||||
model: "websocket_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
export type WebsocketEvent = {
|
||||
model: "websocket_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
isServer: boolean;
|
||||
message: Array<number>;
|
||||
messageType: WebsocketEventType;
|
||||
};
|
||||
|
||||
export type WebsocketEventType =
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketMessageType = "text" | "binary";
|
||||
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
107
crates/yaak-models/bindings/gen_util.ts
generated
Normal file
107
crates/yaak-models/bindings/gen_util.ts
generated
Normal file
@@ -0,0 +1,107 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = {
|
||||
workspaces: Array<Workspace>;
|
||||
environments: Array<Environment>;
|
||||
folders: Array<Folder>;
|
||||
httpRequests: Array<HttpRequest>;
|
||||
grpcRequests: Array<GrpcRequest>;
|
||||
websocketRequests: Array<WebsocketRequest>;
|
||||
};
|
||||
|
||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
|
||||
/**
|
||||
* Where a staged import will be committed.
|
||||
*
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination =
|
||||
| { type: "new_workspace" }
|
||||
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
|
||||
|
||||
/**
|
||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "unchanged"
|
||||
| "keep_local"
|
||||
| "conflict"
|
||||
| "ignored";
|
||||
|
||||
export type ImportPlanItem = {
|
||||
action: ImportPlanAction;
|
||||
model: ImportResourceType;
|
||||
modelId: string;
|
||||
name: string;
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
*/
|
||||
parentId?: string;
|
||||
selected: boolean;
|
||||
resolution?: ImportConflictResolution;
|
||||
reason?: ImportPlanReason;
|
||||
/**
|
||||
* Fields where the source and the local copy disagree, so the preview can say why
|
||||
*/
|
||||
changedFields: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||
|
||||
/**
|
||||
* Whether a plan's note is something to know or something to think twice about.
|
||||
*/
|
||||
export type ImportPlanWarningLevel = "info" | "warning";
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
*/
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
12
crates/yaak-models/blob_migrations/00000000000000_init.sql
Normal file
12
crates/yaak-models/blob_migrations/00000000000000_init.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE body_chunks
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
body_id TEXT NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
|
||||
UNIQUE (body_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_body_chunks_body_id ON body_chunks (body_id, chunk_index);
|
||||
5
crates/yaak-models/build.rs
Normal file
5
crates/yaak-models/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
// Migrations are embedded with include_dir!, so trigger rebuilds when SQL files change.
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
println!("cargo:rerun-if-changed=blob_migrations");
|
||||
}
|
||||
59
crates/yaak-models/guest-js/atoms.test.ts
Normal file
59
crates/yaak-models/guest-js/atoms.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createStore } from "jotai";
|
||||
import { expect, test } from "vitest";
|
||||
import type { HttpResponseEvent } from "../bindings/gen_models";
|
||||
import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms";
|
||||
import { newStoreData } from "./util";
|
||||
|
||||
// The five setting events that every send writes, all within the same millisecond
|
||||
const SETTING_NAMES = [
|
||||
"validate_certificates",
|
||||
"redirects",
|
||||
"timeout",
|
||||
"send_cookies",
|
||||
"store_cookies",
|
||||
];
|
||||
|
||||
function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent {
|
||||
return {
|
||||
model: "http_response_event",
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
workspaceId: "wk_1",
|
||||
responseId: "rs_1",
|
||||
event: { type: "setting", name, value: "true" },
|
||||
};
|
||||
}
|
||||
|
||||
test("events with equal createdAt keep store (DB) insertion order", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
SETTING_NAMES.forEach((name, i) => {
|
||||
data.http_response_event[`hre_${i}`] = settingEvent(
|
||||
`hre_${i}`,
|
||||
name,
|
||||
"2026-08-17T00:00:00.123",
|
||||
);
|
||||
});
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
const names = store.get(httpResponseEventsAtom).map((e) => {
|
||||
return e.event.type === "setting" ? e.event.name : e.event.type;
|
||||
});
|
||||
expect(names).toEqual(SETTING_NAMES);
|
||||
});
|
||||
|
||||
test("events with distinct createdAt sort ascending", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
for (const [id, createdAt] of [
|
||||
["hre_b", "2026-08-17T00:00:00.456"],
|
||||
["hre_a", "2026-08-17T00:00:00.123"],
|
||||
["hre_c", "2026-08-17T00:00:00.789"],
|
||||
]) {
|
||||
data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!);
|
||||
}
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]);
|
||||
});
|
||||
87
crates/yaak-models/guest-js/atoms.ts
Normal file
87
crates/yaak-models/guest-js/atoms.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import type { AnyModel } from "../bindings/gen_models";
|
||||
import { ExtractModel } from "./types";
|
||||
import { newStoreData } from "./util";
|
||||
|
||||
export const modelStoreDataAtom = atom(newStoreData());
|
||||
|
||||
export const cookieJarsAtom = createOrderedModelAtom("cookie_jar", "name", "asc");
|
||||
export const environmentsAtom = createOrderedModelAtom("environment", "sortPriority", "asc");
|
||||
export const foldersAtom = createModelAtom("folder");
|
||||
export const grpcConnectionsAtom = createOrderedModelAtom("grpc_connection", "createdAt", "desc");
|
||||
export const grpcEventsAtom = createOrderedModelAtom("grpc_event", "createdAt", "asc");
|
||||
export const grpcRequestsAtom = createModelAtom("grpc_request");
|
||||
export const httpRequestsAtom = createModelAtom("http_request");
|
||||
export const httpResponsesAtom = createOrderedModelAtom("http_response", "createdAt", "desc");
|
||||
export const httpResponseEventsAtom = createOrderedModelAtom(
|
||||
"http_response_event",
|
||||
"createdAt",
|
||||
"asc",
|
||||
);
|
||||
export const keyValuesAtom = createModelAtom("key_value");
|
||||
export const pluginsAtom = createModelAtom("plugin");
|
||||
export const settingsAtom = createSingularModelAtom("settings");
|
||||
export const websocketRequestsAtom = createModelAtom("websocket_request");
|
||||
export const websocketEventsAtom = createOrderedModelAtom("websocket_event", "createdAt", "asc");
|
||||
export const websocketConnectionsAtom = createOrderedModelAtom(
|
||||
"websocket_connection",
|
||||
"createdAt",
|
||||
"desc",
|
||||
);
|
||||
export const workspaceMetasAtom = createModelAtom("workspace_meta");
|
||||
export const workspacesAtom = createOrderedModelAtom("workspace", "name", "asc");
|
||||
|
||||
export function createModelAtom<M extends AnyModel["model"]>(modelType: M) {
|
||||
return selectAtom(
|
||||
modelStoreDataAtom,
|
||||
(data) => Object.values(data[modelType] ?? {}),
|
||||
shallowEqual,
|
||||
);
|
||||
}
|
||||
|
||||
export function createSingularModelAtom<M extends AnyModel["model"]>(modelType: M) {
|
||||
return selectAtom(modelStoreDataAtom, (data) => {
|
||||
const modelData = Object.values(data[modelType] ?? {});
|
||||
const item = modelData[0];
|
||||
if (item == null) throw new Error("Failed creating singular model with no data: " + modelType);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function createOrderedModelAtom<M extends AnyModel["model"]>(
|
||||
modelType: M,
|
||||
field: keyof ExtractModel<AnyModel, M>,
|
||||
order: "asc" | "desc",
|
||||
) {
|
||||
return selectAtom(
|
||||
modelStoreDataAtom,
|
||||
(data) => {
|
||||
const modelData = data[modelType] ?? {};
|
||||
return Object.values(modelData).sort(
|
||||
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
|
||||
// NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders
|
||||
// equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order.
|
||||
const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1;
|
||||
return order === "desc" ? n * -1 : n;
|
||||
},
|
||||
);
|
||||
},
|
||||
shallowEqual,
|
||||
);
|
||||
}
|
||||
|
||||
function shallowEqual<T>(a: T[], b: T[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
11
crates/yaak-models/guest-js/index.ts
Normal file
11
crates/yaak-models/guest-js/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AnyModel } from "../bindings/gen_models";
|
||||
|
||||
export * from "../bindings/gen_models";
|
||||
export * from "../bindings/gen_util";
|
||||
export * from "./store";
|
||||
export * from "./atoms";
|
||||
|
||||
export function modelTypeLabel(m: AnyModel): string {
|
||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
return m.model.split("_").map(capitalize).join(" ");
|
||||
}
|
||||
413
crates/yaak-models/guest-js/store.ts
Normal file
413
crates/yaak-models/guest-js/store.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { AnyModel, ModelPayload } from "../bindings/gen_models";
|
||||
import { modelStoreDataAtom } from "./atoms";
|
||||
import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
|
||||
import { newStoreData } from "./util";
|
||||
|
||||
let _store: JotaiStore | null = null;
|
||||
|
||||
const pendingModelWrites = new Set<Promise<unknown>>();
|
||||
|
||||
export function initModelStore(store: JotaiStore) {
|
||||
_store = store;
|
||||
|
||||
// Don't lose debounced patches if the window closes while one is pending
|
||||
window.addEventListener("beforeunload", flushAllPendingPatches);
|
||||
|
||||
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
|
||||
// Apply the entire batch in one update, cloning each touched bucket only
|
||||
// once. Bulk writes (imports, sync, CLI) can carry hundreds of models.
|
||||
const next = { ...prev };
|
||||
const clonedBuckets = new Set<AnyModel["model"]>();
|
||||
let changed = false;
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (shouldIgnoreModel(payload)) continue;
|
||||
if (isUnsafeObjectKey(payload.model.model)) continue;
|
||||
if (isUnsafeObjectKey(payload.model.id)) continue;
|
||||
|
||||
if (payload.change.type === "upsert") {
|
||||
const modelType = payload.model.model;
|
||||
if (!clonedBuckets.has(modelType)) {
|
||||
next[modelType] = { ...next[modelType] } as never;
|
||||
clonedBuckets.add(modelType);
|
||||
}
|
||||
(next[modelType] as Record<string, AnyModel>)[payload.model.id] = payload.model;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = applyModelDelete(next, clonedBuckets, payload.model) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Model buckets are plain objects keyed by model id, so ids that collide with
|
||||
* Object.prototype members ("__proto__" etc.) could pollute the prototype.
|
||||
* Real model ids are backend-generated and never look like this.
|
||||
*/
|
||||
function isUnsafeObjectKey(key: string): boolean {
|
||||
return key === "__proto__" || key === "constructor" || key === "prototype";
|
||||
}
|
||||
|
||||
function deleteFromBucket(
|
||||
next: ModelStoreData,
|
||||
clonedBuckets: Set<AnyModel["model"]>,
|
||||
modelType: AnyModel["model"],
|
||||
id: string,
|
||||
): boolean {
|
||||
if (isUnsafeObjectKey(modelType)) return false;
|
||||
if (isUnsafeObjectKey(id)) return false;
|
||||
if (!Object.prototype.hasOwnProperty.call(next[modelType], id)) return false;
|
||||
if (!clonedBuckets.has(modelType)) {
|
||||
next[modelType] = { ...next[modelType] } as never;
|
||||
clonedBuckets.add(modelType);
|
||||
}
|
||||
delete (next[modelType] as Record<string, AnyModel>)[id];
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a model delete to store data, mutating `next` in place (buckets are
|
||||
* cloned once, tracked via `clonedBuckets`). A workspace delete implies its
|
||||
* entire subtree: the backend bulk-deletes children and records/emits only the
|
||||
* workspace event (see ModelChangeEvent), so prune them here.
|
||||
*/
|
||||
function applyModelDelete(
|
||||
next: ModelStoreData,
|
||||
clonedBuckets: Set<AnyModel["model"]>,
|
||||
model: AnyModel,
|
||||
): boolean {
|
||||
let changed = deleteFromBucket(next, clonedBuckets, model.model, model.id);
|
||||
|
||||
if (model.model === "workspace") {
|
||||
for (const modelType of Object.keys(next) as AnyModel["model"][]) {
|
||||
const bucket = next[modelType] as Record<string, AnyModel>;
|
||||
for (const [id, m] of Object.entries(bucket)) {
|
||||
if ("workspaceId" in m && m.workspaceId === model.id) {
|
||||
changed = deleteFromBucket(next, clonedBuckets, modelType, id) || changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function mustStore(): JotaiStore {
|
||||
if (_store == null) {
|
||||
throw new Error("Model store was not initialized");
|
||||
}
|
||||
|
||||
return _store;
|
||||
}
|
||||
|
||||
function trackModelWrite<T>(write: Promise<T>): Promise<T> {
|
||||
const tracked = write.finally(() => {
|
||||
pendingModelWrites.delete(tracked);
|
||||
});
|
||||
|
||||
pendingModelWrites.add(tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export async function flushAllModelWrites(): Promise<void> {
|
||||
flushAllPendingPatches();
|
||||
const results = await Promise.allSettled(pendingModelWrites);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
if (rejected?.status === "rejected") {
|
||||
throw rejected.reason;
|
||||
}
|
||||
}
|
||||
|
||||
const PATCH_DEBOUNCE_MS = 400;
|
||||
|
||||
interface PendingPatch {
|
||||
model: AnyModel["model"];
|
||||
id: string;
|
||||
patch: Record<string, unknown>;
|
||||
write: ReturnType<typeof debounce>;
|
||||
}
|
||||
|
||||
const pendingPatches = new Map<string, PendingPatch>();
|
||||
|
||||
/**
|
||||
* Like patchModel, but coalesces rapid patches to the same model (eg. one per
|
||||
* keystroke) into a single write. Later fields overwrite earlier ones, so it's
|
||||
* only safe for whole-value fields like url, body, or headers. Pending patches
|
||||
* flush after a short delay, and flushAllModelWrites() (called before sends and
|
||||
* duplicates) flushes them immediately.
|
||||
*/
|
||||
export function patchModelDebounced<
|
||||
M extends AnyModel["model"],
|
||||
T extends ExtractModel<AnyModel, M>,
|
||||
>(base: Pick<T, "id" | "model">, patch: Partial<T>): void {
|
||||
const key = `${base.model}.${base.id}`;
|
||||
let pending = pendingPatches.get(key);
|
||||
if (pending == null) {
|
||||
pending = {
|
||||
model: base.model,
|
||||
id: base.id,
|
||||
patch: {},
|
||||
write: debounce(() => writePendingPatch(key), PATCH_DEBOUNCE_MS),
|
||||
};
|
||||
pendingPatches.set(key, pending);
|
||||
}
|
||||
pending.patch = { ...pending.patch, ...patch };
|
||||
pending.write();
|
||||
}
|
||||
|
||||
function writePendingPatch(key: string) {
|
||||
const pending = pendingPatches.get(key);
|
||||
if (pending == null) return;
|
||||
pendingPatches.delete(key);
|
||||
try {
|
||||
void patchModelById(pending.model, pending.id, pending.patch);
|
||||
} catch (err) {
|
||||
// Model may have been deleted while the patch was pending
|
||||
console.warn("Failed to flush pending patch", key, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function flushAllPendingPatches() {
|
||||
for (const pending of Array.from(pendingPatches.values())) {
|
||||
pending.write.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a model's pending patch, if it has one that hasn't been written yet.
|
||||
*
|
||||
* The store only moves forward when the backend echoes a write back, so between a keystroke and
|
||||
* its debounced write the stored copy is behind what the user typed. Reading through the pending
|
||||
* patch keeps that window invisible to the imperative readers below, which are the ones that go
|
||||
* on to write the model back.
|
||||
*/
|
||||
function withPendingPatch<T>(model: T | null): T | null {
|
||||
if (model == null || pendingPatches.size === 0) return model;
|
||||
const { model: modelType, id } = model as { model?: string; id?: string };
|
||||
const pending = pendingPatches.get(`${modelType}.${id}`);
|
||||
return pending == null ? model : ({ ...model, ...pending.patch } as T);
|
||||
}
|
||||
|
||||
/** Drop a model's pending patch and cancel its scheduled write */
|
||||
function consumePendingPatch(model: AnyModel["model"], id: string) {
|
||||
const key = `${model}.${id}`;
|
||||
const pending = pendingPatches.get(key);
|
||||
if (pending == null) return;
|
||||
pending.write.cancel();
|
||||
pendingPatches.delete(key);
|
||||
}
|
||||
|
||||
let _activeWorkspaceId: string | null = null;
|
||||
|
||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||
console.log("Syncing models with new workspace", workspaceId);
|
||||
const workspaceModelsStr = await platform.rpc<string>("models_workspace_models", {
|
||||
workspaceId, // NOTE: if no workspace id provided, it will just fetch global models
|
||||
});
|
||||
const workspaceModels = JSON.parse(workspaceModelsStr) as AnyModel[];
|
||||
const data = newStoreData();
|
||||
for (const model of workspaceModels) {
|
||||
data[model.model][model.id] = model;
|
||||
}
|
||||
|
||||
mustStore().set(modelStoreDataAtom, data);
|
||||
|
||||
console.log("Synced model store with workspace", workspaceId, data);
|
||||
|
||||
_activeWorkspaceId = workspaceId;
|
||||
}
|
||||
|
||||
export function listModels<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
modelType: M | ReadonlyArray<M>,
|
||||
): T[] {
|
||||
let data = mustStore().get(modelStoreDataAtom);
|
||||
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
||||
return types.flatMap((t) => Object.values(data[t]) as T[]);
|
||||
}
|
||||
|
||||
export function getModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
modelType: M | ReadonlyArray<M>,
|
||||
id: string,
|
||||
): T | null {
|
||||
let data = mustStore().get(modelStoreDataAtom);
|
||||
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
||||
for (const t of types) {
|
||||
let v = data[t][id];
|
||||
if (v?.model === t) return withPendingPatch(v as T);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAnyModel(id: string): AnyModel | null {
|
||||
let data = mustStore().get(modelStoreDataAtom);
|
||||
for (const t of Object.keys(data)) {
|
||||
// oxlint-disable-next-line no-explicit-any -- dynamic key access
|
||||
let v = (data as any)[t]?.[id];
|
||||
if (v?.model === t) return withPendingPatch(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function patchModelById<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
model: M,
|
||||
id: string,
|
||||
patch: Partial<T> | ((prev: T) => T),
|
||||
): Promise<string> {
|
||||
// Reads through any pending debounced patch, so the merge below can't put a stale value back
|
||||
// over something the user has already typed
|
||||
let prev = getModel<M, T>(model, id);
|
||||
if (prev == null) {
|
||||
throw new Error(`Failed to get model to patch id=${id} model=${model}`);
|
||||
}
|
||||
|
||||
// `prev` already carries the pending patch, so this write supersedes it. Leaving it queued
|
||||
// would let it land afterwards and undo whatever this write decided.
|
||||
consumePendingPatch(model, id);
|
||||
|
||||
const newModel = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
||||
return updateModel(newModel);
|
||||
}
|
||||
|
||||
export async function patchModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
base: Pick<T, "id" | "model">,
|
||||
patch: Partial<T>,
|
||||
): Promise<string> {
|
||||
return patchModelById<M, T>(base.model, base.id, patch);
|
||||
}
|
||||
|
||||
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
model: T,
|
||||
): Promise<string> {
|
||||
return trackModelWrite(platform.rpc<string>("models_upsert", { model }));
|
||||
}
|
||||
|
||||
export async function deleteModelById<
|
||||
M extends AnyModel["model"],
|
||||
T extends ExtractModel<AnyModel, M>,
|
||||
>(modelType: M | M[], id: string) {
|
||||
let model = getModel<M, T>(modelType, id);
|
||||
await deleteModel(model);
|
||||
}
|
||||
|
||||
export async function deleteModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
model: T | null,
|
||||
) {
|
||||
if (model == null) {
|
||||
throw new Error("Failed to delete null model");
|
||||
}
|
||||
await trackModelWrite(platform.rpc<string>("models_delete", { model }));
|
||||
|
||||
// Apply the delete locally right away so callers can rely on the store once the
|
||||
// promise resolves. The backend echo arrives async, so anything that reads the
|
||||
// store immediately after awaiting (e.g. redirecting away from a deleted
|
||||
// workspace) would otherwise race it. The echo re-applying later is a no-op.
|
||||
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
|
||||
const next = { ...prev };
|
||||
return applyModelDelete(next, new Set(), model as AnyModel) ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
export async function duplicateModel<
|
||||
M extends AnyModel["model"],
|
||||
T extends ExtractModel<AnyModel, M>,
|
||||
>(model: T | null): Promise<string> {
|
||||
if (model == null) {
|
||||
throw new Error("Failed to duplicate null model");
|
||||
}
|
||||
|
||||
// Flush pending writes first, since the backend duplicates from the DB (the passed-in
|
||||
// model may be a stale snapshot, eg. from the memoized sidebar tree). Conflict-free
|
||||
// naming ("Foo Copy 2") is also handled by the backend.
|
||||
await flushAllModelWrites();
|
||||
|
||||
return trackModelWrite(
|
||||
platform.rpc<string>("models_duplicate", { modelType: model.model, modelId: model.id }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
||||
patch: Partial<T> & Pick<T, "model">,
|
||||
): Promise<string> {
|
||||
return trackModelWrite(platform.rpc<string>("models_upsert", { model: patch }));
|
||||
}
|
||||
|
||||
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
|
||||
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
|
||||
): Promise<string> {
|
||||
return trackModelWrite(platform.rpc<string>("models_upsert", { model: patch }));
|
||||
}
|
||||
|
||||
export function replaceModelsInStore<
|
||||
M extends AnyModel["model"],
|
||||
T extends Extract<AnyModel, { model: M }>,
|
||||
>(model: M, models: T[]) {
|
||||
const newModels: Record<string, T> = {};
|
||||
for (const model of models) {
|
||||
newModels[model.id] = model;
|
||||
}
|
||||
|
||||
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
|
||||
return {
|
||||
...prev,
|
||||
[model]: newModels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeModelsInStore<
|
||||
M extends AnyModel["model"],
|
||||
T extends Extract<AnyModel, { model: M }>,
|
||||
>(model: M, models: T[], filter?: (model: T) => boolean) {
|
||||
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
|
||||
const existingModels = { ...prev[model] } as Record<string, T>;
|
||||
|
||||
// Merge in new models first
|
||||
for (const m of models) {
|
||||
existingModels[m.id] = m;
|
||||
}
|
||||
|
||||
// Then filter out unwanted models
|
||||
if (filter) {
|
||||
for (const [id, m] of Object.entries(existingModels)) {
|
||||
if (!filter(m)) {
|
||||
delete existingModels[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[model]: existingModels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIgnoreModel({ model, updateSource }: ModelPayload) {
|
||||
// Never ignore updates from non-user sources
|
||||
if (updateSource.type !== "window") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never ignore same-window updates
|
||||
if (updateSource.label === platform.window.label) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only sync models that belong to this workspace, if a workspace ID is present
|
||||
if ("workspaceId" in model && model.workspaceId !== _activeWorkspaceId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (model.model === "key_value" && model.namespace === "no_sync") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
8
crates/yaak-models/guest-js/types.ts
Normal file
8
crates/yaak-models/guest-js/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createStore } from "jotai";
|
||||
import { AnyModel } from "../bindings/gen_models";
|
||||
|
||||
export type ExtractModel<T, M> = T extends { model: M } ? T : never;
|
||||
export type ModelStoreData<T extends AnyModel = AnyModel> = {
|
||||
[M in T["model"]]: Record<string, Extract<T, { model: M }>>;
|
||||
};
|
||||
export type JotaiStore = ReturnType<typeof createStore>;
|
||||
26
crates/yaak-models/guest-js/util.ts
Normal file
26
crates/yaak-models/guest-js/util.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ModelStoreData } from "./types";
|
||||
|
||||
export function newStoreData(): ModelStoreData {
|
||||
return {
|
||||
cookie_jar: {},
|
||||
environment: {},
|
||||
folder: {},
|
||||
graphql_introspection: {},
|
||||
grpc_connection: {},
|
||||
grpc_event: {},
|
||||
grpc_request: {},
|
||||
http_request: {},
|
||||
http_response: {},
|
||||
http_response_event: {},
|
||||
import_source: {},
|
||||
key_value: {},
|
||||
plugin: {},
|
||||
settings: {},
|
||||
sync_state: {},
|
||||
websocket_connection: {},
|
||||
websocket_event: {},
|
||||
websocket_request: {},
|
||||
workspace: {},
|
||||
workspace_meta: {},
|
||||
};
|
||||
}
|
||||
65
crates/yaak-models/migrations/20230225181302_init.sql
Normal file
65
crates/yaak-models/migrations/20230225181302_init.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
CREATE TABLE key_values
|
||||
(
|
||||
model TEXT DEFAULT 'key_value' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (namespace, key)
|
||||
);
|
||||
|
||||
CREATE TABLE workspaces
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'workspace' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE http_requests
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
body TEXT,
|
||||
body_type TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE http_responses
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_response' NOT NULL,
|
||||
request_id TEXT NOT NULL
|
||||
REFERENCES http_requests
|
||||
ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
elapsed INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
status_reason TEXT,
|
||||
url TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE http_requests ADD COLUMN authentication_type TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
DELETE FROM main.http_responses;
|
||||
ALTER TABLE http_responses DROP COLUMN body;
|
||||
ALTER TABLE http_responses ADD COLUMN body BLOB;
|
||||
ALTER TABLE http_responses ADD COLUMN body_path TEXT;
|
||||
ALTER TABLE http_responses ADD COLUMN content_length INTEGER;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE environments
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'workspace' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
DEFAULT '{}'
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE environments DROP COLUMN data;
|
||||
ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspaces ADD COLUMN variables DEFAULT '[]' NOT NULL;
|
||||
19
crates/yaak-models/migrations/20231103142807_folders.sql
Normal file
19
crates/yaak-models/migrations/20231103142807_folders.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE folders
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'folder' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
folder_id TEXT NULL
|
||||
REFERENCES folders
|
||||
ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
sort_priority REAL DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE http_requests ADD COLUMN folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE;
|
||||
16
crates/yaak-models/migrations/20231112180500_body_object.sql
Normal file
16
crates/yaak-models/migrations/20231112180500_body_object.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Rename old column to backup name
|
||||
ALTER TABLE http_requests
|
||||
RENAME COLUMN body TO body_old;
|
||||
|
||||
-- Create desired new body column
|
||||
ALTER TABLE http_requests
|
||||
ADD COLUMN body TEXT NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Copy data from old to new body, in new JSON format
|
||||
UPDATE http_requests
|
||||
SET body = CASE WHEN body_old IS NULL THEN '{}' ELSE JSON_OBJECT('text', body_old) END
|
||||
WHERE TRUE;
|
||||
|
||||
-- Drop old column
|
||||
ALTER TABLE http_requests
|
||||
DROP COLUMN body_old;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE http_requests
|
||||
ADD COLUMN url_parameters TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE http_responses DROP COLUMN body;
|
||||
13
crates/yaak-models/migrations/20240111221224_settings.sql
Normal file
13
crates/yaak-models/migrations/20240111221224_settings.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE settings
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'settings' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
follow_redirects BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
validate_certificates BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
request_timeout INTEGER DEFAULT 0 NOT NULL,
|
||||
theme TEXT DEFAULT 'default' NOT NULL,
|
||||
appearance TEXT DEFAULT 'system' NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add existing request-related settings to workspace
|
||||
ALTER TABLE workspaces ADD COLUMN setting_request_timeout INTEGER DEFAULT '0' NOT NULL;
|
||||
ALTER TABLE workspaces ADD COLUMN setting_validate_certificates BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
ALTER TABLE workspaces ADD COLUMN setting_follow_redirects BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
|
||||
-- Remove old settings that used to be global
|
||||
ALTER TABLE settings DROP COLUMN request_timeout;
|
||||
ALTER TABLE settings DROP COLUMN follow_redirects;
|
||||
ALTER TABLE settings DROP COLUMN validate_certificates;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL;
|
||||
10
crates/yaak-models/migrations/20240127013915_cookies.sql
Normal file
10
crates/yaak-models/migrations/20240127013915_cookies.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE cookie_jars
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'cookie_jar' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cookies TEXT DEFAULT '[]' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE http_responses ADD COLUMN elapsed_headers INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE http_responses ADD COLUMN remote_addr TEXT;
|
||||
ALTER TABLE http_responses ADD COLUMN version TEXT;
|
||||
68
crates/yaak-models/migrations/20240203164833_grpc.sql
Normal file
68
crates/yaak-models/migrations/20240203164833_grpc.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
CREATE TABLE grpc_requests
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'grpc_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
folder_id TEXT NULL
|
||||
REFERENCES folders
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sort_priority REAL NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
service TEXT NULL,
|
||||
method TEXT NULL,
|
||||
message TEXT NOT NULL,
|
||||
authentication TEXT DEFAULT '{}' NOT NULL,
|
||||
authentication_type TEXT NULL,
|
||||
metadata TEXT DEFAULT '[]' NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE grpc_connections
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'grpc_connection' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
request_id TEXT NOT NULL
|
||||
REFERENCES grpc_requests
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
status INTEGER DEFAULT -1 NOT NULL,
|
||||
error TEXT NULL,
|
||||
elapsed INTEGER DEFAULT 0 NOT NULL,
|
||||
trailers TEXT DEFAULT '{}' NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE grpc_events
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'grpc_event' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
request_id TEXT NOT NULL
|
||||
REFERENCES grpc_requests
|
||||
ON DELETE CASCADE,
|
||||
connection_id TEXT NOT NULL
|
||||
REFERENCES grpc_connections
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
metadata TEXT DEFAULT '{}' NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
status INTEGER NULL,
|
||||
error TEXT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN theme_dark TEXT DEFAULT 'yaak-dark' NOT NULL;
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN theme_light TEXT DEFAULT 'yaak-light' NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE settings ADD COLUMN interface_font_size INTEGER DEFAULT 15 NOT NULL;
|
||||
ALTER TABLE settings ADD COLUMN interface_scale INTEGER DEFAULT 1 NOT NULL;
|
||||
ALTER TABLE settings ADD COLUMN editor_font_size INTEGER DEFAULT 13 NOT NULL;
|
||||
ALTER TABLE settings ADD COLUMN editor_soft_wrap BOOLEAN DEFAULT 1 NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN open_workspace_new_window BOOLEAN NULL DEFAULT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE environments DROP COLUMN model;
|
||||
ALTER TABLE environments ADD COLUMN model TEXT DEFAULT 'environment';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN telemetry BOOLEAN DEFAULT TRUE;
|
||||
12
crates/yaak-models/migrations/20240829131004_plugins.sql
Normal file
12
crates/yaak-models/migrations/20240829131004_plugins.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE plugins
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'plugin' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
checked_at DATETIME NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
directory TEXT NULL NOT NULL,
|
||||
url TEXT NULL
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE http_responses
|
||||
ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL;
|
||||
|
||||
ALTER TABLE grpc_connections
|
||||
ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN proxy TEXT;
|
||||
8
crates/yaak-models/migrations/20241217204951_docs.sql
Normal file
8
crates/yaak-models/migrations/20241217204951_docs.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE http_requests
|
||||
ADD COLUMN description TEXT DEFAULT '' NOT NULL;
|
||||
|
||||
ALTER TABLE grpc_requests
|
||||
ADD COLUMN description TEXT DEFAULT '' NOT NULL;
|
||||
|
||||
ALTER TABLE folders
|
||||
ADD COLUMN description TEXT DEFAULT '' NOT NULL;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Add the new field
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN environment_id TEXT REFERENCES environments (id) ON DELETE CASCADE;
|
||||
|
||||
-- Create temporary column so we know which rows are meant to be base environments. We'll use this to update
|
||||
-- child environments to point to them.
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN migrated_base_env BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
-- Create a base environment for each workspace
|
||||
INSERT INTO environments (id, workspace_id, name, variables, migrated_base_env)
|
||||
SELECT (
|
||||
-- This is the best way to generate a random string in SQLite, apparently
|
||||
'ev_' || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1)
|
||||
),
|
||||
workspaces.id,
|
||||
'Global Variables',
|
||||
variables,
|
||||
TRUE
|
||||
FROM workspaces;
|
||||
|
||||
-- Update all non-base environments to point to newly created base environments
|
||||
UPDATE environments
|
||||
SET environment_id = ( SELECT base_env.id
|
||||
FROM environments AS base_env
|
||||
WHERE base_env.workspace_id = environments.workspace_id
|
||||
AND base_env.migrated_base_env IS TRUE )
|
||||
WHERE migrated_base_env IS FALSE;
|
||||
|
||||
-- Drop temporary column
|
||||
ALTER TABLE environments
|
||||
DROP COLUMN migrated_base_env;
|
||||
|
||||
-- Drop the old variables column
|
||||
-- IMPORTANT: Skip to give the user the option to roll back to a previous app version. We can drop it once the migration working in the real world
|
||||
-- ALTER TABLE workspaces DROP COLUMN variables;
|
||||
21
crates/yaak-models/migrations/20250102141937_sync.sql
Normal file
21
crates/yaak-models/migrations/20250102141937_sync.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN setting_sync_dir TEXT;
|
||||
|
||||
CREATE TABLE sync_states
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'sync_state' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
flushed_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
sync_dir TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
|
||||
UNIQUE (workspace_id, model_id)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN editor_keymap TEXT DEFAULT 'codemirror' NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE workspace_metas
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'workspace_meta' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
setting_sync_dir TEXT
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- This setting was moved to the new workspace_metas table
|
||||
ALTER TABLE workspaces DROP COLUMN setting_sync_dir;
|
||||
11
crates/yaak-models/migrations/20250123192023_plugin-kv.sql
Normal file
11
crates/yaak-models/migrations/20250123192023_plugin-kv.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE plugin_key_values
|
||||
(
|
||||
model TEXT DEFAULT 'plugin_key_value' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
plugin_name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (plugin_name, key)
|
||||
);
|
||||
66
crates/yaak-models/migrations/20250128155623_websockets.sql
Normal file
66
crates/yaak-models/migrations/20250128155623_websockets.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE websocket_requests
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'websocket_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
folder_id TEXT
|
||||
REFERENCES folders
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
authentication TEXT DEFAULT '{}' NOT NULL,
|
||||
authentication_type TEXT,
|
||||
description TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
sort_priority REAL NOT NULL,
|
||||
url_parameters TEXT DEFAULT '[]' NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE websocket_connections
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'websocket_connection' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
request_id TEXT NOT NULL
|
||||
REFERENCES websocket_requests
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
status INTEGER DEFAULT -1 NOT NULL,
|
||||
error TEXT NULL,
|
||||
elapsed INTEGER DEFAULT 0 NOT NULL,
|
||||
headers TEXT DEFAULT '{}' NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE websocket_events
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'websocket_event' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
request_id TEXT NOT NULL
|
||||
REFERENCES websocket_requests
|
||||
ON DELETE CASCADE,
|
||||
connection_id TEXT NOT NULL
|
||||
REFERENCES websocket_connections
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
is_server BOOLEAN NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
message BLOB NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 1. Create the new table with `id` as the primary key
|
||||
CREATE TABLE key_values_new
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
model TEXT DEFAULT 'key_value' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 2. Copy data from the old table
|
||||
INSERT INTO key_values_new (id, model, created_at, updated_at, deleted_at, namespace, key, value)
|
||||
SELECT (
|
||||
-- This is the best way to generate a random string in SQLite, apparently
|
||||
'kv_' || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||
|
||||
SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1)
|
||||
) AS id,
|
||||
model,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
namespace,
|
||||
key,
|
||||
value
|
||||
FROM key_values;
|
||||
|
||||
-- 3. Drop the old table
|
||||
DROP TABLE key_values;
|
||||
|
||||
-- 4. Rename the new table
|
||||
ALTER TABLE key_values_new
|
||||
RENAME TO key_values;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspace_metas ADD COLUMN encryption_key TEXT NULL DEFAULT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspaces ADD COLUMN encryption_key_challenge TEXT NULL;
|
||||
243
crates/yaak-models/migrations/20250424152740_remove-fks.sql
Normal file
243
crates/yaak-models/migrations/20250424152740_remove-fks.sql
Normal file
@@ -0,0 +1,243 @@
|
||||
-- NOTE: SQLite does not support dropping foreign keys, so we need to create new
|
||||
-- tables and copy data instead. To prevent cascade deletes from wrecking stuff,
|
||||
-- we start with the leaf tables and finish with the parent tables (eg. folder).
|
||||
|
||||
----------------------------
|
||||
-- Remove http request FK --
|
||||
----------------------------
|
||||
|
||||
CREATE TABLE http_requests_dg_tmp
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
body_type TEXT,
|
||||
sort_priority REAL DEFAULT 0 NOT NULL,
|
||||
authentication TEXT DEFAULT '{}' NOT NULL,
|
||||
authentication_type TEXT,
|
||||
folder_id TEXT,
|
||||
body TEXT DEFAULT '{}' NOT NULL,
|
||||
url_parameters TEXT DEFAULT '[]' NOT NULL,
|
||||
description TEXT DEFAULT '' NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO http_requests_dg_tmp(id, model, workspace_id, created_at, updated_at, deleted_at, name, url, method,
|
||||
headers, body_type, sort_priority, authentication, authentication_type, folder_id,
|
||||
body, url_parameters, description)
|
||||
SELECT id,
|
||||
model,
|
||||
workspace_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
name,
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body_type,
|
||||
sort_priority,
|
||||
authentication,
|
||||
authentication_type,
|
||||
folder_id,
|
||||
body,
|
||||
url_parameters,
|
||||
description
|
||||
FROM http_requests;
|
||||
|
||||
DROP TABLE http_requests;
|
||||
|
||||
ALTER TABLE http_requests_dg_tmp
|
||||
RENAME TO http_requests;
|
||||
|
||||
----------------------------
|
||||
-- Remove grpc request FK --
|
||||
----------------------------
|
||||
|
||||
CREATE TABLE grpc_requests_dg_tmp
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'grpc_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
folder_id TEXT,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sort_priority REAL NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
service TEXT,
|
||||
method TEXT,
|
||||
message TEXT NOT NULL,
|
||||
authentication TEXT DEFAULT '{}' NOT NULL,
|
||||
authentication_type TEXT,
|
||||
metadata TEXT DEFAULT '[]' NOT NULL,
|
||||
description TEXT DEFAULT '' NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO grpc_requests_dg_tmp(id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, url,
|
||||
service, method, message, authentication, authentication_type, metadata, description)
|
||||
SELECT id,
|
||||
model,
|
||||
workspace_id,
|
||||
folder_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
name,
|
||||
sort_priority,
|
||||
url,
|
||||
service,
|
||||
method,
|
||||
message,
|
||||
authentication,
|
||||
authentication_type,
|
||||
metadata,
|
||||
description
|
||||
FROM grpc_requests;
|
||||
|
||||
DROP TABLE grpc_requests;
|
||||
|
||||
ALTER TABLE grpc_requests_dg_tmp
|
||||
RENAME TO grpc_requests;
|
||||
|
||||
---------------------------------
|
||||
-- Remove websocket request FK --
|
||||
---------------------------------
|
||||
|
||||
CREATE TABLE websocket_requests_dg_tmp
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'websocket_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
folder_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
authentication TEXT DEFAULT '{}' NOT NULL,
|
||||
authentication_type TEXT,
|
||||
description TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
sort_priority REAL NOT NULL,
|
||||
url_parameters TEXT DEFAULT '[]' NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO websocket_requests_dg_tmp(id, model, workspace_id, folder_id, created_at, updated_at, deleted_at,
|
||||
authentication, authentication_type, description, name, url, headers, message,
|
||||
sort_priority, url_parameters)
|
||||
SELECT id,
|
||||
model,
|
||||
workspace_id,
|
||||
folder_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
authentication,
|
||||
authentication_type,
|
||||
description,
|
||||
name,
|
||||
url,
|
||||
headers,
|
||||
message,
|
||||
sort_priority,
|
||||
url_parameters
|
||||
FROM websocket_requests;
|
||||
|
||||
DROP TABLE websocket_requests;
|
||||
|
||||
ALTER TABLE websocket_requests_dg_tmp
|
||||
RENAME TO websocket_requests;
|
||||
|
||||
---------------------------
|
||||
-- Remove environment FK --
|
||||
---------------------------
|
||||
|
||||
CREATE TABLE environments_dg_tmp
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
variables DEFAULT '[]' NOT NULL,
|
||||
model TEXT DEFAULT 'environment',
|
||||
environment_id TEXT
|
||||
);
|
||||
|
||||
INSERT INTO environments_dg_tmp(id, created_at, updated_at, deleted_at, workspace_id, name, variables, model,
|
||||
environment_id)
|
||||
SELECT id,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
workspace_id,
|
||||
name,
|
||||
variables,
|
||||
model,
|
||||
environment_id
|
||||
FROM environments;
|
||||
|
||||
DROP TABLE environments;
|
||||
|
||||
ALTER TABLE environments_dg_tmp
|
||||
RENAME TO environments;
|
||||
|
||||
----------------------
|
||||
-- Remove folder FK --
|
||||
----------------------
|
||||
|
||||
CREATE TABLE folders_dg_tmp
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'folder' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
folder_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
sort_priority REAL DEFAULT 0 NOT NULL,
|
||||
description TEXT DEFAULT '' NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO folders_dg_tmp(id, model, created_at, updated_at, deleted_at, workspace_id, folder_id, name, sort_priority,
|
||||
description)
|
||||
SELECT id,
|
||||
model,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
workspace_id,
|
||||
folder_id,
|
||||
name,
|
||||
sort_priority,
|
||||
description
|
||||
FROM folders;
|
||||
|
||||
DROP TABLE folders;
|
||||
|
||||
ALTER TABLE folders_dg_tmp
|
||||
RENAME TO folders;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- There used to be sync code that skipped over environments because we didn't
|
||||
-- want to sync potentially insecure data. With encryption, it is now possible
|
||||
-- to sync environments securely. However, there were already sync states in the
|
||||
-- DB that marked environments as "Synced". Running the sync code on these envs
|
||||
-- would mark them as deleted by FS (exist in SyncState but not on FS).
|
||||
--
|
||||
-- To undo this mess, we have this migration to delete all environment-related
|
||||
-- sync states so we can sync from a clean slate.
|
||||
DELETE
|
||||
FROM sync_states
|
||||
WHERE model_id LIKE 'ev_%';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Add a public column to represent whether an environment can be shared or exported
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN public BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Add a base column to represent whether an environment is a base or sub environment. We used to
|
||||
-- do this with environment_id, but we need a more flexible solution now that envs can be optionally
|
||||
-- synced. E.g., it's now possible to only import a sub environment from a different client without
|
||||
-- its base environment "parent."
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN base BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- SQLite doesn't support dynamic default values, so we update `base` based on the value of
|
||||
-- environment_id.
|
||||
UPDATE environments
|
||||
SET base = TRUE
|
||||
WHERE environment_id IS NULL;
|
||||
|
||||
-- Finally, we drop the old `environment_id` column that will no longer be used
|
||||
ALTER TABLE environments
|
||||
DROP COLUMN environment_id;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Auth
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE folders
|
||||
ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN authentication_type TEXT;
|
||||
ALTER TABLE folders
|
||||
ADD COLUMN authentication_type TEXT;
|
||||
|
||||
-- Headers
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN headers TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE folders
|
||||
ADD COLUMN headers TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Clean up old key/values that are no longer used
|
||||
DELETE
|
||||
FROM key_values
|
||||
WHERE key LIKE 'graphql_introspection::%';
|
||||
|
||||
CREATE TABLE graphql_introspections
|
||||
(
|
||||
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'graphql_introspection' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
request_id TEXT NULL
|
||||
REFERENCES http_requests
|
||||
ON DELETE CASCADE,
|
||||
content TEXT NULL
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Add sync_dir to the unique index, or else it will fail if the user disables sync
|
||||
-- and re-enables it for a different directory.
|
||||
|
||||
-- Step 1: Rename the existing table
|
||||
ALTER TABLE sync_states
|
||||
RENAME TO sync_states_old;
|
||||
|
||||
-- Step 2: Create the new table with the updated unique constraint
|
||||
CREATE TABLE sync_states
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'sync_state' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
flushed_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
sync_dir TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_sync_states_unique
|
||||
ON sync_states (workspace_id, model_id, sync_dir);
|
||||
|
||||
-- Step 3: Copy the data
|
||||
INSERT INTO sync_states (id, model, workspace_id, created_at, updated_at,
|
||||
flushed_at, checksum, model_id, sync_dir, rel_path)
|
||||
SELECT id,
|
||||
model,
|
||||
workspace_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
flushed_at,
|
||||
checksum,
|
||||
model_id,
|
||||
sync_dir,
|
||||
rel_path
|
||||
FROM sync_states_old;
|
||||
|
||||
-- Step 4: Drop the old table
|
||||
DROP TABLE sync_states_old;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN colored_methods BOOLEAN DEFAULT FALSE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings ADD COLUMN interface_font TEXT;
|
||||
ALTER TABLE settings ADD COLUMN editor_font TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE environments ADD COLUMN color TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN autoupdate BOOLEAN DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Create temporary table for migration
|
||||
CREATE TABLE environments__new
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
variables TEXT DEFAULT '[]' NOT NULL,
|
||||
model TEXT DEFAULT 'environment',
|
||||
public BOOLEAN DEFAULT FALSE,
|
||||
color TEXT,
|
||||
|
||||
-- NEW
|
||||
parent_model TEXT DEFAULT 'workspace' NOT NULL,
|
||||
parent_id TEXT
|
||||
);
|
||||
|
||||
-- Backfill the data from the old table
|
||||
-- - base=1 -> (workspace, NULL)
|
||||
-- - base=0 -> (environment, id_of_workspace_base) (fallback to workspace,NULL if none)
|
||||
INSERT INTO environments__new
|
||||
(id, created_at, updated_at, deleted_at, workspace_id, name, variables, model, public, color, parent_model, parent_id)
|
||||
SELECT
|
||||
e.id,
|
||||
e.created_at,
|
||||
e.updated_at,
|
||||
e.deleted_at,
|
||||
e.workspace_id,
|
||||
e.name,
|
||||
e.variables,
|
||||
e.model,
|
||||
e.public,
|
||||
e.color,
|
||||
CASE
|
||||
WHEN e.base = 1 THEN 'workspace'
|
||||
WHEN (
|
||||
SELECT COUNT(1)
|
||||
FROM environments b
|
||||
WHERE b.workspace_id = e.workspace_id AND b.base = 1
|
||||
) > 0 THEN 'environment'
|
||||
ELSE 'workspace'
|
||||
END AS parent_model,
|
||||
CASE
|
||||
WHEN e.base = 1 THEN NULL
|
||||
ELSE (
|
||||
SELECT b.id
|
||||
FROM environments b
|
||||
WHERE b.workspace_id = e.workspace_id AND b.base = 1
|
||||
ORDER BY b.created_at ASC, b.id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
END AS parent_id
|
||||
FROM environments e;
|
||||
|
||||
-- Move everything to the new table
|
||||
DROP TABLE environments;
|
||||
ALTER TABLE environments__new
|
||||
RENAME TO environments;
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN hide_license_badge BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- 2. Backfill based on old JSON
|
||||
UPDATE settings
|
||||
SET hide_license_badge = 1
|
||||
WHERE EXISTS ( SELECT 1
|
||||
FROM key_values kv
|
||||
WHERE kv.key = 'license_confirmation'
|
||||
AND JSON_EXTRACT(kv.value, '$.confirmedPersonalUse') = TRUE );
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN auto_download_updates BOOLEAN DEFAULT TRUE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN check_notifications BOOLEAN DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
UPDATE http_requests
|
||||
SET authentication_type = 'awsv4'
|
||||
WHERE authentication_type = 'auth-aws-sig-v4';
|
||||
|
||||
UPDATE folders
|
||||
SET authentication_type = 'awsv4'
|
||||
WHERE authentication_type = 'auth-aws-sig-v4';
|
||||
|
||||
UPDATE workspaces
|
||||
SET authentication_type = 'awsv4'
|
||||
WHERE authentication_type = 'auth-aws-sig-v4';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN sort_priority REAL DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add a setting to force native window title bar / controls
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN use_native_titlebar BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN client_certificates TEXT DEFAULT '[]' NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Add default User-Agent header to workspaces that don't already have one (case-insensitive check)
|
||||
UPDATE workspaces
|
||||
SET headers = json_insert(headers, '$[#]', json('{"enabled":true,"name":"User-Agent","value":"yaak"}'))
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM json_each(workspaces.headers)
|
||||
WHERE LOWER(json_extract(value, '$.name')) = 'user-agent'
|
||||
);
|
||||
|
||||
-- Add default Accept header to workspaces that don't already have one (case-insensitive check)
|
||||
UPDATE workspaces
|
||||
SET headers = json_insert(headers, '$[#]', json('{"enabled":true,"name":"Accept","value":"*/*"}'))
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM json_each(workspaces.headers)
|
||||
WHERE LOWER(json_extract(value, '$.name')) = 'accept'
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add request_headers and content_length_compressed columns to http_responses table
|
||||
ALTER TABLE http_responses ADD COLUMN request_headers TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE http_responses ADD COLUMN content_length_compressed INTEGER;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE http_response_events
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_response_event' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
response_id TEXT NOT NULL
|
||||
REFERENCES http_responses
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
event TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE http_responses
|
||||
ADD COLUMN request_content_length INTEGER;
|
||||
1
crates/yaak-models/migrations/20260104000000_hotkeys.sql
Normal file
1
crates/yaak-models/migrations/20260104000000_hotkeys.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add DNS resolution timing to http_responses
|
||||
ALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add DNS overrides setting to workspaces
|
||||
ALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*),
|
||||
-- keeping any other custom headers the user may have added.
|
||||
UPDATE workspaces
|
||||
SET headers = (
|
||||
SELECT json_group_array(json(value))
|
||||
FROM json_each(headers)
|
||||
WHERE NOT (
|
||||
(LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak')
|
||||
OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*')
|
||||
)
|
||||
)
|
||||
WHERE json_array_length(headers) > 0;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE model_changes
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
change TEXT NOT NULL,
|
||||
update_source TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_model_changes_created_at ON model_changes (created_at);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Remove stale plugin rows left over from the brief period when faker shipped as bundled.
|
||||
DELETE FROM plugins
|
||||
WHERE directory LIKE '%template-function-faker';
|
||||
@@ -0,0 +1,33 @@
|
||||
ALTER TABLE plugins
|
||||
ADD COLUMN source TEXT DEFAULT 'filesystem' NOT NULL;
|
||||
|
||||
-- Existing registry installs have a URL; classify them first.
|
||||
UPDATE plugins
|
||||
SET source = 'registry'
|
||||
WHERE url IS NOT NULL;
|
||||
|
||||
-- Best-effort bundled backfill for legacy rows.
|
||||
UPDATE plugins
|
||||
SET source = 'bundled'
|
||||
WHERE source = 'filesystem'
|
||||
AND (
|
||||
-- Normalize separators so this also works for Windows paths.
|
||||
replace(directory, '\', '/') LIKE '%/vendored/plugins/%'
|
||||
OR replace(directory, '\', '/') LIKE '%/vendored-plugins/%'
|
||||
);
|
||||
|
||||
-- Keep one row per exact directory before adding uniqueness.
|
||||
-- Tie-break by recency.
|
||||
WITH ranked AS (SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY directory
|
||||
ORDER BY updated_at DESC,
|
||||
created_at DESC
|
||||
) AS row_num
|
||||
FROM plugins)
|
||||
DELETE
|
||||
FROM plugins
|
||||
WHERE id IN (SELECT id FROM ranked WHERE row_num > 1);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_directory_unique
|
||||
ON plugins (directory);
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE workspaces ADD COLUMN setting_send_cookies BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
ALTER TABLE workspaces ADD COLUMN setting_store_cookies BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
|
||||
ALTER TABLE folders ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE folders ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE folders ADD COLUMN setting_request_timeout TEXT DEFAULT '{"enabled":false,"value":0}' NOT NULL;
|
||||
ALTER TABLE folders ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE folders ADD COLUMN setting_follow_redirects TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
|
||||
ALTER TABLE http_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE http_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE http_requests ADD COLUMN setting_request_timeout TEXT DEFAULT '{"enabled":false,"value":0}' NOT NULL;
|
||||
ALTER TABLE http_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE http_requests ADD COLUMN setting_follow_redirects TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
|
||||
ALTER TABLE websocket_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
ALTER TABLE websocket_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE websocket_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
|
||||
ALTER TABLE grpc_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE workspaces ADD COLUMN setting_request_message_size INTEGER DEFAULT 67108864 NOT NULL;
|
||||
|
||||
ALTER TABLE folders ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
||||
|
||||
ALTER TABLE websocket_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
||||
|
||||
ALTER TABLE grpc_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add a setting to enable in-app feature feedback prompts
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN prompt_feedback BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE workspaces ADD COLUMN setting_http_version TEXT DEFAULT 'auto' NOT NULL;
|
||||
|
||||
ALTER TABLE folders ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||
|
||||
ALTER TABLE http_requests ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE import_sources
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'import_source' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
importer TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
origin_label TEXT NOT NULL,
|
||||
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE import_source_resources
|
||||
(
|
||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
import_source_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
PRIMARY KEY (import_source_id, source_key)
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
|
||||
-- so a resource the user chose not to import can be remembered.
|
||||
CREATE TABLE import_source_resources_new
|
||||
(
|
||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
import_source_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT,
|
||||
content_hash TEXT,
|
||||
PRIMARY KEY (import_source_id, source_key)
|
||||
);
|
||||
|
||||
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
|
||||
source_key, model_type, model_id, content_hash)
|
||||
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
|
||||
FROM import_source_resources;
|
||||
|
||||
DROP TABLE import_source_resources;
|
||||
|
||||
ALTER TABLE import_source_resources_new
|
||||
RENAME TO import_source_resources;
|
||||
6
crates/yaak-models/package.json
Normal file
6
crates/yaak-models/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/models",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "guest-js/index.ts"
|
||||
}
|
||||
414
crates/yaak-models/src/blob_manager.rs
Normal file
414
crates/yaak-models/src/blob_manager.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::util::generate_prefixed_id;
|
||||
use include_dir::{Dir, include_dir};
|
||||
use log::{debug, info};
|
||||
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use std::ops::Deref;
|
||||
use yaak_database::{ConnectionOrTx, SqlitePool};
|
||||
|
||||
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
|
||||
|
||||
/// A chunk of body data stored in the blob database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyChunk {
|
||||
pub id: String,
|
||||
pub body_id: String,
|
||||
pub chunk_index: i32,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BodyChunk {
|
||||
pub fn new(body_id: impl Into<String>, chunk_index: i32, data: Vec<u8>) -> Self {
|
||||
Self { id: generate_prefixed_id("bc"), body_id: body_id.into(), chunk_index, data }
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the blob database: a reader pool and a single writer, for the
|
||||
/// same reason as [`crate::query_manager::QueryManager`].
|
||||
// Pools are internally synchronized — don't wrap them in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlobManager {
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
}
|
||||
|
||||
impl BlobManager {
|
||||
/// `writer` must be a pool with a single connection.
|
||||
pub fn new(readers: SqlitePool, writer: SqlitePool) -> Self {
|
||||
Self { readers, writer }
|
||||
}
|
||||
|
||||
/// A read handle from the reader pool.
|
||||
pub fn connect(&self) -> BlobContext<'_> {
|
||||
let conn = self.readers.get().expect("Failed to get blob DB connection from pool");
|
||||
BlobContext { conn: ConnectionOrTx::Connection(conn) }
|
||||
}
|
||||
|
||||
/// Run `func` in a transaction on the writer connection.
|
||||
pub fn with_tx<T, E>(
|
||||
&self,
|
||||
func: impl FnOnce(&BlobWriter) -> std::result::Result<T, E>,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
.map_err(crate::error::Error::SqlError)?;
|
||||
let writer = BlobWriter { ctx: BlobContext { conn: ConnectionOrTx::Transaction(&tx) } };
|
||||
match func(&writer) {
|
||||
Ok(val) => {
|
||||
tx.commit().map_err(|e| {
|
||||
GenericError(format!("Failed to commit blob transaction {e:?}"))
|
||||
})?;
|
||||
Ok(val)
|
||||
}
|
||||
Err(e) => {
|
||||
tx.rollback().map_err(|e| {
|
||||
GenericError(format!("Failed to rollback blob transaction {e:?}"))
|
||||
})?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read handle for the blob database.
|
||||
pub struct BlobContext<'a> {
|
||||
conn: ConnectionOrTx<'a>,
|
||||
}
|
||||
|
||||
impl<'a> BlobContext<'a> {
|
||||
/// Get all chunks for a body, ordered by chunk_index.
|
||||
pub fn get_chunks(&self, body_id: &str) -> Result<Vec<BodyChunk>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, body_id, chunk_index, data FROM body_chunks
|
||||
WHERE body_id = ?1 ORDER BY chunk_index ASC",
|
||||
)?;
|
||||
|
||||
let chunks = stmt
|
||||
.query_map(params![body_id], |row| {
|
||||
Ok(BodyChunk {
|
||||
id: row.get(0)?,
|
||||
body_id: row.get(1)?,
|
||||
chunk_index: row.get(2)?,
|
||||
data: row.get(3)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
/// List all distinct body IDs in the blob database.
|
||||
pub fn list_body_ids(&self) -> Result<Vec<String>> {
|
||||
let mut stmt = self.conn.prepare("SELECT DISTINCT body_id FROM body_chunks")?;
|
||||
let ids = stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<String>, _>>()?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Get total size of a body without loading data.
|
||||
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
|
||||
let size: i64 = self
|
||||
.conn
|
||||
.resolve()
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(size as usize)
|
||||
}
|
||||
|
||||
/// Check if a body exists.
|
||||
pub fn body_exists(&self, body_id: &str) -> Result<bool> {
|
||||
let count: i64 = self
|
||||
.conn
|
||||
.resolve()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write handle for the blob database. Derefs to [`BlobContext`] for reads.
|
||||
pub struct BlobWriter<'a> {
|
||||
ctx: BlobContext<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for BlobWriter<'a> {
|
||||
type Target = BlobContext<'a>;
|
||||
|
||||
fn deref(&self) -> &BlobContext<'a> {
|
||||
&self.ctx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BlobWriter<'a> {
|
||||
/// Insert a single chunk.
|
||||
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks for a body.
|
||||
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
|
||||
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all
|
||||
/// bodies for a response).
|
||||
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run migrations for the blob database.
|
||||
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
info!("Running blob database migrations");
|
||||
|
||||
// Create migrations tracking table
|
||||
pool.get()?.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _blob_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Read and sort all .sql files
|
||||
let mut entries: Vec<_> = BLOB_MIGRATIONS_DIR
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false))
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|e| e.path());
|
||||
|
||||
let mut ran_migrations = 0;
|
||||
for entry in &entries {
|
||||
let filename = entry.path().file_name().unwrap().to_str().unwrap();
|
||||
let version = filename.split('_').next().unwrap();
|
||||
|
||||
// Check if already applied
|
||||
let already_applied: Option<i64> = pool
|
||||
.get()?
|
||||
.query_row("SELECT 1 FROM _blob_migrations WHERE version = ?", [version], |r| r.get(0))
|
||||
.optional()?;
|
||||
|
||||
if already_applied.is_some() {
|
||||
debug!("Skipping already applied blob migration: {}", filename);
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql =
|
||||
entry.as_file().unwrap().contents_utf8().expect("Failed to read blob migration file");
|
||||
|
||||
info!("Applying blob migration: {}", filename);
|
||||
let conn = pool.get()?;
|
||||
conn.execute_batch(sql)?;
|
||||
|
||||
// Record migration
|
||||
conn.execute(
|
||||
"INSERT INTO _blob_migrations (version, description) VALUES (?, ?)",
|
||||
params![version, filename],
|
||||
)?;
|
||||
|
||||
ran_migrations += 1;
|
||||
}
|
||||
|
||||
if ran_migrations == 0 {
|
||||
info!("No blob migrations to run");
|
||||
} else {
|
||||
info!("Ran {} blob migration(s)", ran_migrations);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
|
||||
fn create_test_manager() -> BlobManager {
|
||||
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
|
||||
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
|
||||
migrate_blob_db(&pool).unwrap();
|
||||
BlobManager::new(pool.clone(), pool)
|
||||
}
|
||||
|
||||
fn insert(manager: &BlobManager, chunks: &[BodyChunk]) {
|
||||
manager
|
||||
.with_tx(|b| {
|
||||
for c in chunks {
|
||||
b.insert_chunk(c)?;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_get_chunks() {
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 0, b"Hello, ".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"World!".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let chunks = manager.connect().get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[0].chunk_index, 0);
|
||||
assert_eq!(chunks[0].data, b"Hello, ");
|
||||
assert_eq!(chunks[1].chunk_index, 1);
|
||||
assert_eq!(chunks[1].data, b"World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_chunks_ordered_by_index() {
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
// Insert out of order
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 2, b"C".to_vec()),
|
||||
BodyChunk::new(body_id, 0, b"A".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"B".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let chunks = manager.connect().get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 3);
|
||||
assert_eq!(chunks[0].data, b"A");
|
||||
assert_eq!(chunks[1].data, b"B");
|
||||
assert_eq!(chunks[2].data, b"C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_chunks() {
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
insert(&manager, &[BodyChunk::new(body_id, 0, b"data".to_vec())]);
|
||||
assert!(manager.connect().body_exists(body_id).unwrap());
|
||||
|
||||
manager.with_tx(|b| b.delete_chunks(body_id)).unwrap();
|
||||
|
||||
let ctx = manager.connect();
|
||||
assert!(!ctx.body_exists(body_id).unwrap());
|
||||
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_chunks_like() {
|
||||
let manager = create_test_manager();
|
||||
// Insert chunks for same response but different body types
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new("rs_abc.request", 0, b"req".to_vec()),
|
||||
BodyChunk::new("rs_abc.response", 0, b"resp".to_vec()),
|
||||
BodyChunk::new("rs_other.request", 0, b"other".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
// Delete all bodies for rs_abc
|
||||
manager.with_tx(|b| b.delete_chunks_like("rs_abc.%")).unwrap();
|
||||
|
||||
let ctx = manager.connect();
|
||||
assert!(!ctx.body_exists("rs_abc.request").unwrap());
|
||||
assert!(!ctx.body_exists("rs_abc.response").unwrap());
|
||||
assert!(ctx.body_exists("rs_other.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_body_size() {
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 0, b"Hello".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"World".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let size = manager.connect().get_body_size(body_id).unwrap();
|
||||
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_body_size_empty() {
|
||||
let manager = create_test_manager();
|
||||
let size = manager.connect().get_body_size("nonexistent").unwrap();
|
||||
assert_eq!(size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_exists() {
|
||||
let manager = create_test_manager();
|
||||
assert!(!manager.connect().body_exists("rs_test.request").unwrap());
|
||||
|
||||
insert(&manager, &[BodyChunk::new("rs_test.request", 0, b"data".to_vec())]);
|
||||
|
||||
assert!(manager.connect().body_exists("rs_test.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_bodies_isolated() {
|
||||
let manager = create_test_manager();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new("body1", 0, b"data1".to_vec()),
|
||||
BodyChunk::new("body2", 0, b"data2".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let ctx = manager.connect();
|
||||
let chunks1 = ctx.get_chunks("body1").unwrap();
|
||||
let chunks2 = ctx.get_chunks("body2").unwrap();
|
||||
|
||||
assert_eq!(chunks1.len(), 1);
|
||||
assert_eq!(chunks1[0].data, b"data1");
|
||||
assert_eq!(chunks2.len(), 1);
|
||||
assert_eq!(chunks2[0].data, b"data2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_chunk() {
|
||||
let manager = create_test_manager();
|
||||
// 1MB chunk
|
||||
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
|
||||
let body_id = "rs_large.request";
|
||||
insert(&manager, &[BodyChunk::new(body_id, 0, large_data.clone())]);
|
||||
|
||||
let ctx = manager.connect();
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].data, large_data);
|
||||
assert_eq!(ctx.get_body_size(body_id).unwrap(), 1024 * 1024);
|
||||
}
|
||||
}
|
||||
176
crates/yaak-models/src/client_db.rs
Normal file
176
crates/yaak-models/src/client_db.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::error::Result;
|
||||
use crate::models::{AnyModel, UpsertModelInfo};
|
||||
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
|
||||
use rusqlite::params;
|
||||
use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
use yaak_database::DbContext;
|
||||
|
||||
/// A read handle. Comes from the reader pool and can only query.
|
||||
///
|
||||
/// Anything that changes a row lives on [`WriteDb`], which is only ever handed
|
||||
/// out inside a transaction on the single writer connection. That split is
|
||||
/// what keeps the pool from filling with writers waiting on each other: there
|
||||
/// is one writer, so there is never a second one to wait for.
|
||||
pub struct ClientDb<'a> {
|
||||
pub(crate) ctx: DbContext<'a>,
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Access the underlying connection for custom queries.
|
||||
pub(crate) fn conn(&self) -> &yaak_database::ConnectionOrTx<'a> {
|
||||
self.ctx.conn()
|
||||
}
|
||||
|
||||
pub(crate) fn find_one<M>(
|
||||
&self,
|
||||
col: impl IntoColumnRef + IntoIden + Clone,
|
||||
value: impl Into<SimpleExpr> + Debug,
|
||||
) -> Result<M>
|
||||
where
|
||||
M: UpsertModelInfo,
|
||||
{
|
||||
Ok(self.ctx.find_one(col, value)?)
|
||||
}
|
||||
|
||||
pub(crate) fn find_optional<M>(
|
||||
&self,
|
||||
col: impl IntoColumnRef,
|
||||
value: impl Into<SimpleExpr>,
|
||||
) -> Option<M>
|
||||
where
|
||||
M: UpsertModelInfo,
|
||||
{
|
||||
self.ctx.find_optional(col, value)
|
||||
}
|
||||
|
||||
pub(crate) fn find_all<M>(&self) -> Result<Vec<M>>
|
||||
where
|
||||
M: UpsertModelInfo,
|
||||
{
|
||||
Ok(self.ctx.find_all()?)
|
||||
}
|
||||
|
||||
pub(crate) fn find_many<M>(
|
||||
&self,
|
||||
col: impl IntoColumnRef,
|
||||
value: impl Into<SimpleExpr>,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<M>>
|
||||
where
|
||||
M: UpsertModelInfo,
|
||||
{
|
||||
Ok(self.ctx.find_many(col, value, limit)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A write handle: a [`ClientDb`] on the writer connection, inside a
|
||||
/// transaction, that can also change rows. Derefs to [`ClientDb`] so every
|
||||
/// query is available while writing, and reads inside the transaction see
|
||||
/// its own uncommitted writes.
|
||||
///
|
||||
/// Model events are held back until the transaction commits; a rollback
|
||||
/// discards them along with the rows.
|
||||
pub struct WriteDb<'a> {
|
||||
db: ClientDb<'a>,
|
||||
pending_events: RefCell<Vec<ModelPayload>>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for WriteDb<'a> {
|
||||
type Target = ClientDb<'a>;
|
||||
|
||||
fn deref(&self) -> &ClientDb<'a> {
|
||||
&self.db
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>) -> Self {
|
||||
Self { db: ClientDb::new(ctx), pending_events: RefCell::new(Vec::new()) }
|
||||
}
|
||||
|
||||
/// The events for everything written so far, to send once the
|
||||
/// transaction has committed.
|
||||
pub(crate) fn into_events(self) -> Vec<ModelPayload> {
|
||||
self.pending_events.into_inner()
|
||||
}
|
||||
|
||||
/// Bulk-delete all rows matching a column value WITHOUT recording model
|
||||
/// changes or emitting events. Only use for cascades whose deletion is
|
||||
/// implied by a recorded parent delete (e.g. workspace children — see
|
||||
/// [`ModelChangeEvent::Delete`]).
|
||||
pub(crate) fn delete_many_untracked<M>(
|
||||
&self,
|
||||
col: impl IntoColumnRef,
|
||||
value: impl Into<SimpleExpr>,
|
||||
) -> Result<usize>
|
||||
where
|
||||
M: UpsertModelInfo,
|
||||
{
|
||||
Ok(self.ctx.delete_many::<M>(col, value)?)
|
||||
}
|
||||
|
||||
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
|
||||
where
|
||||
M: Into<AnyModel> + UpsertModelInfo + Clone,
|
||||
{
|
||||
let (m, created) = self.ctx.upsert(model, &source.to_db())?;
|
||||
|
||||
let payload = ModelPayload {
|
||||
model: m.clone().into(),
|
||||
update_source: source.clone(),
|
||||
change: ModelChangeEvent::Upsert { created },
|
||||
};
|
||||
|
||||
self.record_model_change(&payload)?;
|
||||
self.pending_events.borrow_mut().push(payload);
|
||||
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub(crate) fn delete<M>(&self, m: &M, source: &UpdateSource) -> Result<M>
|
||||
where
|
||||
M: Into<AnyModel> + Clone + UpsertModelInfo,
|
||||
{
|
||||
self.ctx.delete(m)?;
|
||||
|
||||
let payload = ModelPayload {
|
||||
model: m.clone().into(),
|
||||
update_source: source.clone(),
|
||||
change: ModelChangeEvent::Delete,
|
||||
};
|
||||
|
||||
self.record_model_change(&payload)?;
|
||||
self.pending_events.borrow_mut().push(payload);
|
||||
|
||||
Ok(m.clone())
|
||||
}
|
||||
|
||||
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)?;
|
||||
let change_json = serde_json::to_string(&payload.change)?;
|
||||
|
||||
self.ctx.conn().resolve().execute(
|
||||
r#"
|
||||
INSERT INTO model_changes (model, model_id, change, update_source, payload)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
"#,
|
||||
params![
|
||||
payload.model.model(),
|
||||
payload.model.id(),
|
||||
change_json,
|
||||
source_json,
|
||||
payload_json,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
crates/yaak-models/src/connection_or_tx.rs
Normal file
1
crates/yaak-models/src/connection_or_tx.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use yaak_database::connection_or_tx::ConnectionOrTx;
|
||||
107
crates/yaak-models/src/cookies.rs
Normal file
107
crates/yaak-models/src/cookies.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Carrying a send's cookie changes back into a jar.
|
||||
//!
|
||||
//! A send starts from a snapshot of the jar and hands back the jar as the
|
||||
//! transaction left it. Writing that whole result over the jar would also
|
||||
//! write over anything the user changed *while* the send was in flight — a
|
||||
//! cookie edited or deleted in the jar view, or set by another send. So the
|
||||
//! send's contribution is taken as a difference (what it added, changed, or
|
||||
//! removed relative to its snapshot) and applied to whatever the jar holds now.
|
||||
|
||||
use crate::models::{Cookie, CookieDomain};
|
||||
|
||||
/// The identity of a cookie in a jar: two cookies with the same name, domain
|
||||
/// and path are the same cookie, whatever their value or attributes.
|
||||
type CookieKey = (String, CookieDomain, String);
|
||||
|
||||
fn key(c: &Cookie) -> CookieKey {
|
||||
(c.name.clone(), c.domain.clone(), c.path.clone())
|
||||
}
|
||||
|
||||
/// Apply the changes between `before` (the snapshot a send started from) and
|
||||
/// `after` (the jar as the send left it) to `current` (the jar as it is now).
|
||||
///
|
||||
/// Cookies the send removed are removed; cookies it added or changed replace
|
||||
/// their counterpart in `current`, or are appended. Cookies the send did not
|
||||
/// touch are left exactly as `current` has them.
|
||||
pub fn apply_cookie_changes(
|
||||
current: Vec<Cookie>,
|
||||
before: &[Cookie],
|
||||
after: &[Cookie],
|
||||
) -> Vec<Cookie> {
|
||||
let removed: Vec<CookieKey> =
|
||||
before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect();
|
||||
let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect();
|
||||
|
||||
let mut result: Vec<Cookie> =
|
||||
current.into_iter().filter(|c| !removed.contains(&key(c))).collect();
|
||||
for cookie in changed {
|
||||
match result.iter_mut().find(|c| key(c) == key(cookie)) {
|
||||
Some(existing) => *existing = cookie.clone(),
|
||||
None => result.push(cookie.clone()),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::CookieExpires;
|
||||
|
||||
fn cookie(name: &str, value: &str) -> Cookie {
|
||||
Cookie {
|
||||
name: name.to_string(),
|
||||
value: value.to_string(),
|
||||
domain: CookieDomain::HostOnly("example.com".to_string()),
|
||||
expires: CookieExpires::SessionEnd,
|
||||
path: "/".to_string(),
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_that_changed_nothing_leaves_the_jar_alone() {
|
||||
let before = vec![cookie("a", "1")];
|
||||
let current = vec![cookie("a", "edited"), cookie("b", "2")];
|
||||
assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additions_and_changes_land_without_touching_concurrent_edits() {
|
||||
let before = vec![cookie("a", "1"), cookie("b", "2")];
|
||||
let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")];
|
||||
// Meanwhile the user edited `a` and added `d`.
|
||||
let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")];
|
||||
assert_eq!(
|
||||
apply_cookie_changes(current, &before, &after),
|
||||
vec![
|
||||
cookie("a", "edited"),
|
||||
cookie("b", "3"),
|
||||
cookie("d", "5"),
|
||||
cookie("c", "4")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cookie_the_send_removed_is_removed() {
|
||||
let before = vec![cookie("a", "1"), cookie("b", "2")];
|
||||
let after = vec![cookie("b", "2")];
|
||||
let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")];
|
||||
assert_eq!(
|
||||
apply_cookie_changes(current, &before, &after),
|
||||
vec![cookie("b", "2"), cookie("c", "3")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() {
|
||||
let before = vec![cookie("a", "1")];
|
||||
let after = vec![cookie("a", "1")]; // untouched by the send
|
||||
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]);
|
||||
let after = vec![cookie("a", "fresh")]; // the send set it again
|
||||
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]);
|
||||
}
|
||||
}
|
||||
69
crates/yaak-models/src/error.rs
Normal file
69
crates/yaak-models/src/error.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use serde::{Serialize, Serializer};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("SQL error: {0}")]
|
||||
SqlError(#[from] rusqlite::Error),
|
||||
|
||||
#[error("SQL Pool error: {0}")]
|
||||
SqlPoolError(#[from] yaak_database::PoolError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Model not found: {0}")]
|
||||
ModelNotFound(String),
|
||||
|
||||
#[error("Model serialization error: {0}")]
|
||||
ModelSerializationError(String),
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
GenericError(String),
|
||||
|
||||
#[error("DB Migration Failed: {0}")]
|
||||
MigrationError(String),
|
||||
|
||||
#[error("No base environment for {0}")]
|
||||
MissingBaseEnvironment(String),
|
||||
|
||||
#[error("Invalid environment selection: {0}")]
|
||||
InvalidEnvironment(String),
|
||||
|
||||
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
|
||||
MultipleBaseEnvironments(String),
|
||||
|
||||
#[error("unknown error")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<yaak_database::Error> for Error {
|
||||
fn from(e: yaak_database::Error) -> Self {
|
||||
match e {
|
||||
yaak_database::Error::SqlError(e) => Error::SqlError(e),
|
||||
yaak_database::Error::SqlPoolError(e) => Error::SqlPoolError(e),
|
||||
yaak_database::Error::Database(s) => Error::Database(s),
|
||||
yaak_database::Error::Io(e) => Error::Io(e),
|
||||
yaak_database::Error::JsonError(e) => Error::JsonError(e),
|
||||
yaak_database::Error::ModelNotFound(s) => Error::ModelNotFound(s),
|
||||
yaak_database::Error::MigrationError(s) => Error::MigrationError(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
104
crates/yaak-models/src/example.rs
Normal file
104
crates/yaak-models/src/example.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! The example workspace offered during onboarding.
|
||||
//!
|
||||
//! Authored as JSON with `{{PLACEHOLDER}}` ids so requests can reference each
|
||||
//! other (the chaining example names another request by id). Every placeholder
|
||||
//! gets a fresh id per creation, so the example can be created more than once.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::{BatchUpsertResult, UpdateSource};
|
||||
|
||||
const EXAMPLE_JSON: &str = include_str!("example_workspace.json");
|
||||
|
||||
const WORKSPACE_IDS: &[&str] = &["WORKSPACE"];
|
||||
const ENVIRONMENT_IDS: &[&str] = &["ENV_BASE", "ENV_USER_2"];
|
||||
const FOLDER_IDS: &[&str] = &[
|
||||
"FOLDER_BASICS",
|
||||
"FOLDER_VARIABLES",
|
||||
"FOLDER_CHAINING",
|
||||
"FOLDER_AUTH",
|
||||
];
|
||||
const REQUEST_IDS: &[&str] = &[
|
||||
"RQ_LIST_POSTS",
|
||||
"RQ_GET_POST",
|
||||
"RQ_CREATE_POST",
|
||||
"RQ_CURRENT_USER",
|
||||
"RQ_LIST_USERS",
|
||||
"RQ_POSTS_BY_FIRST_USER",
|
||||
"RQ_TODOS",
|
||||
"RQ_LOG_IN",
|
||||
"RQ_ME",
|
||||
];
|
||||
|
||||
pub fn create_example_workspace(
|
||||
query_manager: &QueryManager,
|
||||
source: &UpdateSource,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let resources = example_resources()?;
|
||||
Ok(query_manager.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
resources.workspaces,
|
||||
resources.environments,
|
||||
resources.folders,
|
||||
resources.http_requests,
|
||||
resources.grpc_requests,
|
||||
resources.websocket_requests,
|
||||
source,
|
||||
)
|
||||
})?)
|
||||
}
|
||||
|
||||
fn example_resources() -> Result<BatchUpsertResult> {
|
||||
let mut json = EXAMPLE_JSON.to_string();
|
||||
let placeholders = WORKSPACE_IDS
|
||||
.iter()
|
||||
.map(|p| (*p, Workspace::generate_id()))
|
||||
.chain(ENVIRONMENT_IDS.iter().map(|p| (*p, Environment::generate_id())))
|
||||
.chain(FOLDER_IDS.iter().map(|p| (*p, Folder::generate_id())))
|
||||
.chain(REQUEST_IDS.iter().map(|p| (*p, HttpRequest::generate_id())));
|
||||
for (placeholder, id) in placeholders {
|
||||
json = json.replace(&format!("{{{{{placeholder}}}}}"), &id);
|
||||
}
|
||||
debug_assert!(!json.contains("{{"), "example workspace has an unmapped placeholder");
|
||||
Ok(serde_json::from_str(&json)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::init_in_memory;
|
||||
|
||||
#[test]
|
||||
fn every_placeholder_is_mapped() {
|
||||
let json = serde_json::to_string(&example_resources().unwrap()).unwrap();
|
||||
assert!(!json.contains("{{"), "unmapped placeholder in {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_request_references_a_created_request() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||
let created = create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||
assert_eq!(created.workspaces.len(), 1);
|
||||
let workspace_id = &created.workspaces[0].id;
|
||||
|
||||
let db = query_manager.connect();
|
||||
let requests = db.list_http_requests(workspace_id).unwrap();
|
||||
assert_eq!(requests.len(), REQUEST_IDS.len());
|
||||
assert_eq!(db.list_folders(workspace_id).unwrap().len(), FOLDER_IDS.len());
|
||||
assert_eq!(db.list_environments(workspace_id).unwrap().len(), ENVIRONMENT_IDS.len());
|
||||
|
||||
let list_users = requests.iter().find(|r| r.name == "List users").unwrap();
|
||||
let chained = requests.iter().find(|r| r.name == "Posts by the first user").unwrap();
|
||||
let param = &chained.url_parameters[0].value;
|
||||
assert!(param.contains(&format!("request='{}'", list_users.id)), "{param}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_twice_makes_two_workspaces() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||
assert_eq!(query_manager.connect().list_workspaces().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
181
crates/yaak-models/src/example_workspace.json
Normal file
181
crates/yaak-models/src/example_workspace.json
Normal file
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"workspaces": [
|
||||
{
|
||||
"id": "{{WORKSPACE}}",
|
||||
"name": "Example Workspace",
|
||||
"description": "A small tour of Yaak using the [Yaak Playground](https://yaak.run) API. Nothing here needs an account.\n\n1. Open **Getting Started / List posts** and press Send\n2. Open **Variables / Get the current user**, then switch the environment in the top-left to change which user it fetches\n3. Open **Chaining / Posts by the first user** to see how one request reads a value out of another request's response\n4. Open **Authentication / Get my profile** to see a login token used for you\n\nDelete this workspace whenever you're done with it."
|
||||
}
|
||||
],
|
||||
"environments": [
|
||||
{
|
||||
"id": "{{ENV_BASE}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Global Variables",
|
||||
"parentModel": "workspace",
|
||||
"public": true,
|
||||
"variables": [
|
||||
{ "name": "base_url", "value": "https://yaak.run", "enabled": true },
|
||||
{ "name": "user_id", "value": "1", "enabled": true },
|
||||
{ "name": "username", "value": "ada", "enabled": true },
|
||||
{ "name": "password", "value": "yaak", "enabled": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "{{ENV_USER_2}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Another User",
|
||||
"parentModel": "environment",
|
||||
"public": true,
|
||||
"color": "#c084fc",
|
||||
"variables": [
|
||||
{ "name": "user_id", "value": "2", "enabled": true },
|
||||
{ "name": "username", "value": "grace", "enabled": true }
|
||||
]
|
||||
}
|
||||
],
|
||||
"folders": [
|
||||
{
|
||||
"id": "{{FOLDER_BASICS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Getting Started",
|
||||
"sortPriority": 1,
|
||||
"description": "Plain requests. Send one, then look through the response tabs: Body, Headers, Cookies, and Timeline."
|
||||
},
|
||||
{
|
||||
"id": "{{FOLDER_VARIABLES}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Variables",
|
||||
"sortPriority": 2,
|
||||
"description": "Anything wrapped in `${[ ... ]}` is a template. Variables come from the environment selected in the top-left, and sub-environments override the base values."
|
||||
},
|
||||
{
|
||||
"id": "{{FOLDER_CHAINING}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Chaining",
|
||||
"sortPriority": 3,
|
||||
"description": "The `response()` template function reads a value out of another request's response, sending that request first if it has to. Put the cursor in a URL and press Ctrl+Space to insert one."
|
||||
},
|
||||
{
|
||||
"id": "{{FOLDER_AUTH}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Authentication",
|
||||
"sortPriority": 4,
|
||||
"description": "This folder sets Bearer auth to the token from the **Log in** response. Every request inside inherits it, so the token lives in one place. When the last login is more than 50 minutes old, Yaak logs in again before sending.",
|
||||
"authenticationType": "bearer",
|
||||
"authentication": {
|
||||
"token": "${[ response.body.path(request='{{RQ_LOG_IN}}', path='$.accessToken', behavior='ttl', ttl='3000') ]}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"httpRequests": [
|
||||
{
|
||||
"id": "{{RQ_LIST_POSTS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_BASICS}}",
|
||||
"name": "List posts",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/posts",
|
||||
"sortPriority": 1,
|
||||
"urlParameters": [{ "name": "limit", "value": "5", "enabled": true }],
|
||||
"description": "Query parameters live in the Params tab. Try raising `limit`."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_GET_POST}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_BASICS}}",
|
||||
"name": "Get a post",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/posts/1",
|
||||
"sortPriority": 2,
|
||||
"description": "Change the ID at the end of the URL and send again."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_CREATE_POST}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_BASICS}}",
|
||||
"name": "Create a post",
|
||||
"method": "POST",
|
||||
"url": "${[ base_url ]}/posts",
|
||||
"sortPriority": 3,
|
||||
"bodyType": "application/json",
|
||||
"body": {
|
||||
"text": "{\n \"title\": \"Hello from Yaak\",\n \"body\": \"Templates work inside bodies too\",\n \"userId\": ${[ user_id ]}\n}"
|
||||
},
|
||||
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
|
||||
"description": "Posts you create are only visible to you, and go back to the sample data after an hour. Put the new ID into **Get a post** to read it back."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_CURRENT_USER}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_VARIABLES}}",
|
||||
"name": "Get the current user",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/users/${[ user_id ]}",
|
||||
"sortPriority": 1,
|
||||
"description": "Switch to the **Another User** environment in the top-left and send again. Hover a template in the URL to see what it resolves to."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_TODOS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_VARIABLES}}",
|
||||
"name": "List the user's todos",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/users/${[ user_id ]}/todos",
|
||||
"sortPriority": 2,
|
||||
"urlParameters": [{ "name": "completed", "value": "false", "enabled": true }],
|
||||
"description": "The same `user_id` variable, used in a different request. Disable the `completed` parameter to see finished todos too."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_LIST_USERS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_CHAINING}}",
|
||||
"name": "List users",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/users",
|
||||
"sortPriority": 1
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_POSTS_BY_FIRST_USER}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_CHAINING}}",
|
||||
"name": "Posts by the first user",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/posts",
|
||||
"sortPriority": 2,
|
||||
"urlParameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"value": "${[ response.body.path(request='{{RQ_LIST_USERS}}', path='$[0].id') ]}",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"description": "The `userId` parameter is pulled from the **List users** response with the JSONPath `$[0].id`. If that request hasn't been sent yet, Yaak sends it first."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_LOG_IN}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_AUTH}}",
|
||||
"name": "Log in",
|
||||
"method": "POST",
|
||||
"url": "${[ base_url ]}/auth/token",
|
||||
"sortPriority": 1,
|
||||
"authenticationType": "none",
|
||||
"bodyType": "application/json",
|
||||
"body": {
|
||||
"text": "{\n \"username\": \"${[ username ]}\",\n \"password\": \"${[ password ]}\"\n}"
|
||||
},
|
||||
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
|
||||
"description": "Sends the username and password from the environment and returns a token. Its Auth tab is set to No Auth, so it doesn't try to use the token it's fetching."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_ME}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_AUTH}}",
|
||||
"name": "Get my profile",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/auth/me",
|
||||
"sortPriority": 2,
|
||||
"description": "Open the Auth tab: it inherits Bearer auth from the folder, and there's nothing to copy and paste. Check the Timeline tab after sending to see the Authorization header that was sent. To try it as someone else, switch to **Another User** and send **Log in** again."
|
||||
}
|
||||
]
|
||||
}
|
||||
33
crates/yaak-models/src/export.rs
Normal file
33
crates/yaak-models/src/export.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! Building an export document.
|
||||
//!
|
||||
//! Here rather than in `yaak` because the whole of it is this crate: a connection, the
|
||||
//! resources, and JSON. Putting it where the browser can reach it too is what keeps a
|
||||
//! tab's export from being a second implementation that drifts.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::get_workspace_export_resources;
|
||||
|
||||
pub struct ExportDataParams<'a> {
|
||||
pub query_manager: &'a QueryManager,
|
||||
pub yaak_version: &'a str,
|
||||
pub workspace_ids: Vec<&'a str>,
|
||||
pub include_private_environments: bool,
|
||||
}
|
||||
|
||||
/// The export document, as JSON.
|
||||
///
|
||||
/// Returned rather than written: where an export goes is the host's to decide, and a browser
|
||||
/// tab has no path to be handed. The desktop hands the bytes to its save dialog, the CLI
|
||||
/// writes them, a tab downloads them. None of that is this function's business.
|
||||
pub fn export_data(params: ExportDataParams<'_>) -> Result<String> {
|
||||
let db = params.query_manager.connect();
|
||||
let export_data = get_workspace_export_resources(
|
||||
&db,
|
||||
params.yaak_version,
|
||||
params.workspace_ids,
|
||||
params.include_private_environments,
|
||||
)?;
|
||||
|
||||
Ok(serde_json::to_string_pretty(&export_data)?)
|
||||
}
|
||||
183
crates/yaak-models/src/lib.rs
Normal file
183
crates/yaak-models/src/lib.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use crate::blob_manager::{BlobManager, migrate_blob_db};
|
||||
use crate::error::Result;
|
||||
use crate::migrate::migrate_db;
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::ModelPayload;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::SqlitePool;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
mod connection_or_tx;
|
||||
pub mod cookies;
|
||||
pub mod error;
|
||||
pub mod example;
|
||||
pub mod export;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
pub mod models_ops;
|
||||
pub mod path_placeholders;
|
||||
pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
pub mod util;
|
||||
|
||||
/// Per-connection setup, applied by every pool on every connection it opens.
|
||||
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.busy_timeout(std::time::Duration::from_millis(5000))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn init_file_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
init_connection(conn)
|
||||
}
|
||||
|
||||
/// The two ways a pool comes to exist, one per target.
|
||||
///
|
||||
/// On the desktop and CLI, an r2d2 pool over a file. In a browser, a single
|
||||
/// connection over whatever VFS the host registered before calling in — the
|
||||
/// path is a name inside that VFS, not a place on disk. Everything downstream
|
||||
/// of `SqlitePool` is target-agnostic; this is the only fork.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod open {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn file_pool(path: impl Into<PathBuf>, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
|
||||
let path: PathBuf = path.into();
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
|
||||
Pool::builder()
|
||||
.max_size(max_size)
|
||||
.min_idle(Some(min_idle))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
/// `(readers, writer)` over one file: a pool of `max_size` readers and a
|
||||
/// pool of exactly one writer.
|
||||
pub fn file_pools(
|
||||
path: impl Into<PathBuf>,
|
||||
max_size: u32,
|
||||
min_idle: u32,
|
||||
) -> Result<(SqlitePool, SqlitePool)> {
|
||||
let path: PathBuf = path.into();
|
||||
Ok((file_pool(&path, max_size, min_idle)?, file_pool(&path, 1, 1)?))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
|
||||
// In-memory DB doesn't support multiple connections
|
||||
Pool::builder().max_size(1).build(manager).map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod open {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn file_pool(
|
||||
path: impl Into<PathBuf>,
|
||||
_max_size: u32,
|
||||
_min_idle: u32,
|
||||
) -> Result<SqlitePool> {
|
||||
// No WAL: the browser VFSs are single-connection and journal their own
|
||||
// way; the pragma is accepted and ignored on some and rejected on
|
||||
// others, so it is not applied at all here.
|
||||
let conn = Connection::open(path.into())?;
|
||||
init_connection(&conn)?;
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
|
||||
/// One connection is all a browser VFS allows, so it reads and writes.
|
||||
pub fn file_pools(
|
||||
path: impl Into<PathBuf>,
|
||||
max_size: u32,
|
||||
min_idle: u32,
|
||||
) -> Result<(SqlitePool, SqlitePool)> {
|
||||
let pool = file_pool(path, max_size, min_idle)?;
|
||||
Ok((pool.clone(), pool))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
init_connection(&conn)?;
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the database managers for standalone (non-Tauri) usage.
|
||||
///
|
||||
/// Returns a tuple of (QueryManager, BlobManager, event_receiver).
|
||||
/// The event_receiver can be used to listen for model change events.
|
||||
pub fn init_standalone(
|
||||
db_path: impl AsRef<Path>,
|
||||
blob_path: impl AsRef<Path>,
|
||||
) -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
|
||||
let db_path = db_path.as_ref();
|
||||
let blob_path = blob_path.as_ref();
|
||||
|
||||
// Each database gets a reader pool and a one-connection writer pool; see
|
||||
// `QueryManager` for why. Reader pools are sized for concurrent in-flight
|
||||
// queries, not concurrent app features — connections are held per-statement,
|
||||
// so even heavy fan-out (e.g. many gRPC streams) only needs a handful at once.
|
||||
// Keep them modest: WAL connections hold ~3 file descriptors each, and macOS
|
||||
// GUI apps get a 256 fd soft limit.
|
||||
info!("Initializing app database {db_path:?}");
|
||||
let (readers, writer) = open::file_pools(db_path, 20, 2)?;
|
||||
migrate_db(&writer)?;
|
||||
|
||||
info!("Initializing blobs database {blob_path:?}");
|
||||
let (blob_readers, blob_writer) = open::file_pools(blob_path, 10, 1)?;
|
||||
migrate_blob_db(&blob_writer)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let query_manager = QueryManager::new(readers, writer, tx);
|
||||
let blob_manager = BlobManager::new(blob_readers, blob_writer);
|
||||
bootstrap(&query_manager)?;
|
||||
|
||||
Ok((query_manager, blob_manager, rx))
|
||||
}
|
||||
|
||||
/// Initialize the database managers with in-memory SQLite databases.
|
||||
/// Useful for testing and CI environments.
|
||||
///
|
||||
/// An in-memory database is private to its connection, so the one connection
|
||||
/// is both the reader pool and the writer.
|
||||
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
|
||||
let pool = open::memory_pool()?;
|
||||
migrate_db(&pool)?;
|
||||
|
||||
let blob_pool = open::memory_pool()?;
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let query_manager = QueryManager::new(pool.clone(), pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool.clone(), blob_pool);
|
||||
bootstrap(&query_manager)?;
|
||||
|
||||
Ok((query_manager, blob_manager, rx))
|
||||
}
|
||||
|
||||
/// The rows every client assumes exist. Workspaces are not among them: a fresh
|
||||
/// install has none, and the client shows onboarding until the user makes one.
|
||||
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
|
||||
query_manager.with_tx(|tx| {
|
||||
tx.ensure_settings()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
147
crates/yaak-models/src/migrate.rs
Normal file
147
crates/yaak-models/src/migrate.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use crate::error::Error::MigrationError;
|
||||
use crate::error::Result;
|
||||
use include_dir::{Dir, DirEntry, include_dir};
|
||||
use log::{debug, info};
|
||||
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use sha2::{Digest, Sha384};
|
||||
use yaak_database::SqlitePool;
|
||||
|
||||
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
||||
|
||||
pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
|
||||
info!("Running database migrations");
|
||||
|
||||
// Ensure the table exists
|
||||
// NOTE: Yaak used to use sqlx for migrations, so we need to mirror that table structure. We
|
||||
// are writing checksum but not verifying because we want to be able to change migrations after
|
||||
// a release in case something breaks.
|
||||
pool.get()?.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _sqlx_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
installed_on TIMESTAMP default CURRENT_TIMESTAMP NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
checksum BLOB NOT NULL,
|
||||
execution_time BIGINT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Read and sort all .sql files
|
||||
let mut entries = MIGRATIONS_DIR
|
||||
.entries()
|
||||
.into_iter()
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Ensure they're in the correct order
|
||||
entries.sort_by_key(|e| e.path());
|
||||
|
||||
// Run each migration in a transaction
|
||||
let mut num_migrations = 0;
|
||||
let mut ran_migrations = 0;
|
||||
for entry in entries {
|
||||
num_migrations += 1;
|
||||
let conn = pool.get()?;
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let mut tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
|
||||
match run_migration(entry, &mut tx) {
|
||||
Ok(ran) => {
|
||||
if ran {
|
||||
ran_migrations += 1;
|
||||
}
|
||||
tx.commit()?
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!(
|
||||
"{} failed with {}",
|
||||
entry.path().file_name().unwrap().to_str().unwrap(),
|
||||
e.to_string()
|
||||
);
|
||||
tx.rollback()?;
|
||||
return Err(MigrationError(msg));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if ran_migrations == 0 {
|
||||
info!("No migrations to run out of {}", num_migrations);
|
||||
} else {
|
||||
info!("Ran {}/{} migrations", ran_migrations, num_migrations);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
|
||||
let start = elapsed_timer();
|
||||
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
|
||||
.expect("Failed to parse migration filename");
|
||||
|
||||
// Skip if already applied
|
||||
let row: Option<i64> = tx
|
||||
.query_row("SELECT 1 FROM _sqlx_migrations WHERE version = ?", [version.clone()], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
if row.is_some() {
|
||||
debug!("Skipping already run migration {description}");
|
||||
return Ok(false); // Migration was already run
|
||||
}
|
||||
|
||||
let sql =
|
||||
migration_path.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
|
||||
info!("Applying migration {description}");
|
||||
|
||||
// Split on `;`? → optional depending on how your SQL is structured
|
||||
tx.execute_batch(&sql)?;
|
||||
|
||||
let execution_time = start();
|
||||
let checksum = sha384_hex_prefixed(sql.as_bytes());
|
||||
|
||||
// NOTE: The success column is never used. It's just there for sqlx compatibility.
|
||||
tx.execute(
|
||||
"INSERT INTO _sqlx_migrations (version, description, execution_time, checksum, success) VALUES (?, ?, ?, ?, ?)",
|
||||
params![version, description, execution_time, checksum, true],
|
||||
)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Nanoseconds since the timer was started, for the sqlx-compatible
|
||||
/// `execution_time` column. `Instant` does not exist on `wasm32-unknown-unknown`
|
||||
/// (there is no monotonic clock to ask), and the column is bookkeeping, so
|
||||
/// there it reads as zero rather than taking the migrator down with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn elapsed_timer() -> impl Fn() -> i64 {
|
||||
let start = std::time::Instant::now();
|
||||
move || start.elapsed().as_nanos() as i64
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn elapsed_timer() -> impl Fn() -> i64 {
|
||||
|| 0
|
||||
}
|
||||
|
||||
fn split_migration_filename(filename: &str) -> Option<(String, String)> {
|
||||
// Remove the .sql extension
|
||||
let trimmed = filename.strip_suffix(".sql")?;
|
||||
|
||||
// Split on the first underscore
|
||||
let mut parts = trimmed.splitn(2, '_');
|
||||
let version = parts.next()?.to_string();
|
||||
let description = parts.next()?.to_string();
|
||||
|
||||
Some((version, description))
|
||||
}
|
||||
|
||||
fn sha384_hex_prefixed(input: &[u8]) -> String {
|
||||
let mut hasher = Sha384::new();
|
||||
hasher.update(input);
|
||||
let result = hasher.finalize();
|
||||
|
||||
// Format as 0x... with uppercase hex
|
||||
format!("0x{}", hex::encode_upper(result))
|
||||
}
|
||||
3358
crates/yaak-models/src/models.rs
Normal file
3358
crates/yaak-models/src/models.rs
Normal file
File diff suppressed because it is too large
Load Diff
89
crates/yaak-models/src/models_ops.rs
Normal file
89
crates/yaak-models/src/models_ops.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Generic model writes, shared by every host.
|
||||
//!
|
||||
//! `upsert`, `delete` and `duplicate` take an `AnyModel` and fan out to the
|
||||
//! typed query for its variant. That fan-out is long, mechanical, and has to
|
||||
//! grow a new arm every time a model is added — exactly the code that should
|
||||
//! not exist twice. The host supplies the database handles and the
|
||||
//! `UpdateSource` identifying who is writing; nothing here knows whether the
|
||||
//! caller is a desktop window or an HTTP request.
|
||||
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::WriteDb;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::models::AnyModel;
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
pub fn upsert_model(
|
||||
db: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Deletes cascade, so callers run this inside a transaction.
|
||||
pub fn delete_model(
|
||||
tx: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, blobs)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Duplicates recurse, so callers run this inside a transaction.
|
||||
///
|
||||
/// The model is re-read from the database rather than taken from the caller, so
|
||||
/// a duplicate never comes from a stale frontend snapshot.
|
||||
pub fn duplicate_model(
|
||||
tx: &WriteDb,
|
||||
model_type: &str,
|
||||
model_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model_type {
|
||||
"environment" => tx.duplicate_environment(&tx.get_environment(model_id)?, source)?.id,
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(model_id)?, source)?.id,
|
||||
"grpc_request" => tx.duplicate_grpc_request(&tx.get_grpc_request(model_id)?, source)?.id,
|
||||
"http_request" => tx.duplicate_http_request(&tx.get_http_request(model_id)?, source)?.id,
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
220
crates/yaak-models/src/path_placeholders.rs
Normal file
220
crates/yaak-models/src/path_placeholders.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use crate::models::HttpUrlParameter;
|
||||
|
||||
pub fn apply_path_placeholders(
|
||||
url: &str,
|
||||
parameters: &Vec<HttpUrlParameter>,
|
||||
) -> (String, Vec<HttpUrlParameter>) {
|
||||
let mut new_parameters = Vec::new();
|
||||
|
||||
let mut url = url.to_string();
|
||||
for p in parameters {
|
||||
if !p.enabled || p.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace path parameters with values from URL parameters
|
||||
let old_url_string = url.clone();
|
||||
url = replace_path_placeholder(&p, url.as_str());
|
||||
|
||||
// Remove as param if it modified the URL
|
||||
if old_url_string == *url {
|
||||
new_parameters.push(p.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
(url, new_parameters)
|
||||
}
|
||||
|
||||
fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
|
||||
if !p.enabled {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
if !p.name.starts_with(":") {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
// A placeholder is `/` followed by the parameter's name (which starts with `:`), and it
|
||||
// ends at `/`, `?`, `#`, a literal `:`, or the end of the URL. The `:` boundary is what
|
||||
// lets `/:id:increment-importance` substitute the `:id` placeholder while leaving
|
||||
// `:increment-importance` as literal text. `/:foooo` is not a match for `:foo`.
|
||||
//
|
||||
// A plain scan rather than a regex: the name is matched literally, so a name containing
|
||||
// `.` or `+` means exactly that, and nothing else in the model layer needs a regex engine.
|
||||
let name = p.name.as_str();
|
||||
let value = urlencoding::encode(p.value.as_str());
|
||||
let mut result = String::with_capacity(url.len());
|
||||
let mut rest = url;
|
||||
while let Some(slash) = rest.find('/') {
|
||||
let after_slash = &rest[slash + 1..];
|
||||
let is_placeholder = after_slash.starts_with(name)
|
||||
&& after_slash[name.len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|c| matches!(c, '/' | '?' | '#' | ':'));
|
||||
if is_placeholder {
|
||||
result.push_str(&rest[..=slash]);
|
||||
result.push_str(&value);
|
||||
rest = &after_slash[name.len()..];
|
||||
} else {
|
||||
result.push_str(&rest[..=slash]);
|
||||
rest = after_slash;
|
||||
}
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod placeholder_tests {
|
||||
use crate::models::{HttpRequest, HttpUrlParameter};
|
||||
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
|
||||
|
||||
#[test]
|
||||
fn placeholder_middle() {
|
||||
let p =
|
||||
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:foo/bar"),
|
||||
"https://example.com/xxx/bar",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_end() {
|
||||
let p =
|
||||
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:foo"),
|
||||
"https://example.com/xxx",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_query() {
|
||||
let p =
|
||||
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:foo?:foo"),
|
||||
"https://example.com/xxx?:foo",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_followed_by_literal_colon() {
|
||||
// AIP-136-style custom method: `:id` is the placeholder, `:increment-importance`
|
||||
// is literal text in the same path segment.
|
||||
let p =
|
||||
HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"),
|
||||
"https://example.com/tasks/42:increment-importance",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_name_is_matched_literally() {
|
||||
// `.` in a name is a dot, not "any character".
|
||||
let p = HttpUrlParameter {
|
||||
name: ":id.v2".into(),
|
||||
value: "xxx".into(),
|
||||
enabled: true,
|
||||
id: None,
|
||||
};
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:id.v2/:idXv2"),
|
||||
"https://example.com/xxx/:idXv2",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_repeated() {
|
||||
let p = HttpUrlParameter { name: ":id".into(), value: "7".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:id/:id"),
|
||||
"https://example.com/7/7",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_missing() {
|
||||
let p = HttpUrlParameter {
|
||||
enabled: true,
|
||||
name: "".to_string(),
|
||||
value: "".to_string(),
|
||||
id: None,
|
||||
};
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:missing"),
|
||||
"https://example.com/:missing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_disabled() {
|
||||
let p = HttpUrlParameter {
|
||||
enabled: false,
|
||||
name: ":foo".to_string(),
|
||||
value: "xxx".to_string(),
|
||||
id: None,
|
||||
};
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:foo"),
|
||||
"https://example.com/:foo",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prefix() {
|
||||
let p =
|
||||
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:foooo"),
|
||||
"https://example.com/:foooo",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_encode() {
|
||||
let p = HttpUrlParameter {
|
||||
name: ":foo".into(),
|
||||
value: "Hello World".into(),
|
||||
enabled: true,
|
||||
id: None,
|
||||
};
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:foo"),
|
||||
"https://example.com/Hello%20World",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_placeholder() {
|
||||
let req = HttpRequest {
|
||||
url: "example.com/:a/bar".to_string(),
|
||||
url_parameters: vec![
|
||||
HttpUrlParameter {
|
||||
name: "b".to_string(),
|
||||
value: "bbb".to_string(),
|
||||
enabled: true,
|
||||
id: None,
|
||||
},
|
||||
HttpUrlParameter {
|
||||
name: ":a".to_string(),
|
||||
value: "aaa".to_string(),
|
||||
enabled: true,
|
||||
id: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (url, url_parameters) = apply_path_placeholders(&req.url, &req.url_parameters);
|
||||
|
||||
// Pattern match back to access it
|
||||
assert_eq!(url, "example.com/aaa/bar");
|
||||
assert_eq!(url_parameters.len(), 1);
|
||||
assert_eq!(url_parameters[0].name, "b");
|
||||
assert_eq!(url_parameters[0].value, "bbb");
|
||||
}
|
||||
}
|
||||
21
crates/yaak-models/src/queries/any_request.rs
Normal file
21
crates/yaak-models/src/queries/any_request.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
|
||||
pub enum AnyRequest {
|
||||
HttpRequest(HttpRequest),
|
||||
GrpcRequest(GrpcRequest),
|
||||
WebsocketRequest(WebsocketRequest),
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
|
||||
if let Ok(http_request) = self.get_http_request(id) {
|
||||
Ok(AnyRequest::HttpRequest(http_request))
|
||||
} else if let Ok(grpc_request) = self.get_grpc_request(id) {
|
||||
Ok(AnyRequest::GrpcRequest(grpc_request))
|
||||
} else {
|
||||
Ok(AnyRequest::WebsocketRequest(self.get_websocket_request(id)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user