Decouple core Yaak logic from Tauri (#354)

This commit is contained in:
Gregory Schier
2026-01-08 20:44:25 -08:00
committed by GitHub
parent 3bcc0b8356
commit ef80216ca1
465 changed files with 3052 additions and 6234 deletions

View File

@@ -0,0 +1,23 @@
[package]
name = "yaak-models"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
hex = { workspace = true }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.25.0" }
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }

View File

@@ -0,0 +1,96 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | 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 = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
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 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: 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, };
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, url: string, };
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, urlParameters: Array<HttpUrlParameter>, };
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, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: 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, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: 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, };
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" };
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, };
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
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, 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" | "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, urlParameters: Array<HttpUrlParameter>, };
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, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

4
crates/yaak-models/bindings/gen_util.ts generated Normal file
View File

@@ -0,0 +1,4 @@
// 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>, };

View 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);

View File

@@ -0,0 +1,81 @@
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>) => {
const n = 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;
}

View 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(' ');
}

View File

@@ -0,0 +1,230 @@
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { resolvedModelName } from '@yaakapp/app/lib/resolvedModelName';
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;
export function initModelStore(store: JotaiStore) {
_store = store;
getCurrentWebviewWindow()
.listen<ModelPayload>('model_write', ({ payload }) => {
if (shouldIgnoreModel(payload)) return;
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
if (payload.change.type === 'upsert') {
return {
...prev,
[payload.model.model]: {
...prev[payload.model.model],
[payload.model.id]: payload.model,
},
};
} else {
const modelData = { ...prev[payload.model.model] };
delete modelData[payload.model.id];
return { ...prev, [payload.model.model]: modelData };
}
});
})
.catch(console.error);
}
function mustStore(): JotaiStore {
if (_store == null) {
throw new Error('Model store was not initialized');
}
return _store;
}
let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) {
console.log('Syncing models with new workspace', workspaceId);
const workspaceModelsStr = await invoke<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 v as T;
}
return null;
}
export function getAnyModel(
id: string,
): AnyModel | null {
let data = mustStore().get(modelStoreDataAtom);
for (const t of Object.keys(data)) {
let v = (data as any)[t]?.[id];
if (v?.model === t) return 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> {
let prev = getModel<M, T>(model, id);
if (prev == null) {
throw new Error(`Failed to get model to patch id=${id} model=${model}`);
}
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 invoke<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 invoke<string>('models_delete', { model });
}
export function duplicateModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
model: T | null,
) {
if (model == null) {
throw new Error('Failed to duplicate null model');
}
// If the model has a name, try to duplicate it with a name that doesn't conflict
let name = 'name' in model ? resolvedModelName(model) : undefined;
if (name != null) {
const existingModels = listModels(model.model);
for (let i = 0; i < 100; i++) {
const hasConflict = existingModels.some((m) => {
if ('folderId' in m && 'folderId' in model && model.folderId !== m.folderId) {
return false;
} else if (resolvedModelName(m) !== name) {
return false;
}
return true;
});
if (!hasConflict) {
break;
}
// Name conflict. Try another one
const m: RegExpMatchArray | null = name.match(/ Copy( (?<n>\d+))?$/);
if (m != null && m.groups?.n == null) {
name = name.substring(0, m.index) + ' Copy 2';
} else if (m != null && m.groups?.n != null) {
name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`;
} else {
name = `${name} Copy`;
}
}
}
return invoke<string>('models_duplicate', { model: { ...model, name } });
}
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
patch: Partial<T> & Pick<T, 'model'>,
): Promise<string> {
return invoke<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 invoke<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,
};
});
}
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 === getCurrentWebviewWindow().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;
}

View 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>;

View File

@@ -0,0 +1,25 @@
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: {},
key_value: {},
plugin: {},
settings: {},
sync_state: {},
websocket_connection: {},
websocket_event: {},
websocket_request: {},
workspace: {},
workspace_meta: {},
};
}

View 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
);

View File

@@ -0,0 +1 @@
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
ALTER TABLE http_requests ADD COLUMN authentication_type TEXT;

View File

@@ -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;

View File

@@ -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 '{}'
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments DROP COLUMN data;
ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE workspaces ADD COLUMN variables DEFAULT '[]' NOT NULL;

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE http_requests
ADD COLUMN url_parameters TEXT NOT NULL DEFAULT '[]';

View File

@@ -0,0 +1 @@
ALTER TABLE http_responses DROP COLUMN body;

View 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
);

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL;

View 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
);

View File

@@ -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;

View 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
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN open_workspace_new_window BOOLEAN NULL DEFAULT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments DROP COLUMN model;
ALTER TABLE environments ADD COLUMN model TEXT DEFAULT 'environment';

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN telemetry BOOLEAN DEFAULT TRUE;

View 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
);

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN proxy TEXT;

View 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;

View File

@@ -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;

View 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)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN editor_keymap TEXT DEFAULT 'codemirror' NOT NULL;

View File

@@ -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
);

View File

@@ -0,0 +1,2 @@
-- This setting was moved to the new workspace_metas table
ALTER TABLE workspaces DROP COLUMN setting_sync_dir;

View 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)
);

View 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
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL;

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE workspace_metas ADD COLUMN encryption_key TEXT NULL DEFAULT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE workspaces ADD COLUMN encryption_key_challenge TEXT NULL;

View 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;

View File

@@ -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_%';

View File

@@ -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;

View File

@@ -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 '[]';

View File

@@ -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
);

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN colored_methods BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN interface_font TEXT;
ALTER TABLE settings ADD COLUMN editor_font TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE environments ADD COLUMN color TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN autoupdate BOOLEAN DEFAULT true NOT NULL;

View File

@@ -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;

View File

@@ -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 );

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN auto_download_updates BOOLEAN DEFAULT TRUE;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN check_notifications BOOLEAN DEFAULT true NOT NULL;

View File

@@ -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';

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments
ADD COLUMN sort_priority REAL DEFAULT 0 NOT NULL;

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN client_certificates TEXT DEFAULT '[]' NOT NULL;

View File

@@ -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'
);

View File

@@ -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;

View File

@@ -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
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE http_responses
ADD COLUMN request_content_length INTEGER;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL;

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/models",
"private": true,
"version": "1.0.0",
"main": "guest-js/index.ts"
}

View File

@@ -0,0 +1,354 @@
use crate::error::Result;
use crate::util::generate_prefixed_id;
use include_dir::{Dir, include_dir};
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params};
use std::sync::{Arc, Mutex};
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 connection pool.
#[derive(Debug, Clone)]
pub struct BlobManager {
pool: Arc<Mutex<Pool<SqliteConnectionManager>>>,
}
impl BlobManager {
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
Self { pool: Arc::new(Mutex::new(pool)) }
}
pub fn connect(&self) -> BlobContext {
let conn = self
.pool
.lock()
.expect("Failed to gain lock on blob DB")
.get()
.expect("Failed to get blob DB connection from pool");
BlobContext { conn }
}
}
/// Context for blob database operations.
pub struct BlobContext {
conn: r2d2::PooledConnection<SqliteConnectionManager>,
}
impl BlobContext {
/// 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(())
}
/// 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)
}
/// 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(())
}
}
/// Get total size of a body without loading data.
impl BlobContext {
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
let size: i64 = self
.conn
.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
.query_row(
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
params![body_id],
|row| row.get(0),
)
.unwrap_or(0);
Ok(count > 0)
}
}
/// Run migrations for the blob database.
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> 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::*;
fn create_test_pool() -> Pool<SqliteConnectionManager> {
let manager = SqliteConnectionManager::memory();
let pool = Pool::builder().max_size(1).build(manager).unwrap();
migrate_blob_db(&pool).unwrap();
pool
}
#[test]
fn test_insert_and_get_chunks() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let body_id = "rs_test123.request";
let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec());
let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec());
ctx.insert_chunk(&chunk1).unwrap();
ctx.insert_chunk(&chunk2).unwrap();
let chunks = ctx.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 pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let body_id = "rs_test123.request";
// Insert out of order
ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap();
let chunks = ctx.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 pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let body_id = "rs_test123.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap();
assert!(ctx.body_exists(body_id).unwrap());
ctx.delete_chunks(body_id).unwrap();
assert!(!ctx.body_exists(body_id).unwrap());
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
}
#[test]
fn test_delete_chunks_like() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
// Insert chunks for same response but different body types
ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap();
// Delete all bodies for rs_abc
ctx.delete_chunks_like("rs_abc.%").unwrap();
// rs_abc bodies should be gone
assert!(!ctx.body_exists("rs_abc.request").unwrap());
assert!(!ctx.body_exists("rs_abc.response").unwrap());
// rs_other should still exist
assert!(ctx.body_exists("rs_other.request").unwrap());
}
#[test]
fn test_get_body_size() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let body_id = "rs_test123.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap();
let size = ctx.get_body_size(body_id).unwrap();
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
}
#[test]
fn test_get_body_size_empty() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let size = ctx.get_body_size("nonexistent").unwrap();
assert_eq!(size, 0);
}
#[test]
fn test_body_exists() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
assert!(!ctx.body_exists("rs_test.request").unwrap());
ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap();
assert!(ctx.body_exists("rs_test.request").unwrap());
}
#[test]
fn test_multiple_bodies_isolated() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap();
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 pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
// 1MB chunk
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
let body_id = "rs_large.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap();
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);
}
}

View File

@@ -0,0 +1,25 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, Statement, ToSql, Transaction};
pub enum ConnectionOrTx<'a> {
Connection(PooledConnection<SqliteConnectionManager>),
Transaction(&'a Transaction<'a>),
}
impl<'a> ConnectionOrTx<'a> {
pub(crate) fn resolve(&self) -> &Connection {
match self {
ConnectionOrTx::Connection(c) => c,
ConnectionOrTx::Transaction(c) => c,
}
}
pub(crate) fn prepare(&self, sql: &str) -> rusqlite::Result<Statement<'_>> {
self.resolve().prepare(sql)
}
pub(crate) fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> rusqlite::Result<usize> {
self.resolve().execute(sql, params)
}
}

View File

@@ -0,0 +1,211 @@
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Error::ModelNotFound;
use crate::error::Result;
use crate::models::{AnyModel, UpsertModelInfo};
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
use log::error;
use rusqlite::OptionalExtension;
use sea_query::{
Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
};
use sea_query_rusqlite::RusqliteBinder;
use std::fmt::Debug;
use std::sync::mpsc;
pub struct DbContext<'a> {
pub(crate) events_tx: mpsc::Sender<ModelPayload>,
pub(crate) conn: ConnectionOrTx<'a>,
}
impl<'a> DbContext<'a> {
pub(crate) fn find_one<'s, M>(
&self,
col: impl IntoColumnRef + IntoIden + Clone,
value: impl Into<SimpleExpr> + Debug,
) -> Result<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
let value_debug = format!("{:?}", value);
let value_expr = value.into();
let (sql, params) = Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col.clone()).eq(value_expr))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
match stmt.query_row(&*params.as_params(), M::from_row) {
Ok(result) => Ok(result),
Err(rusqlite::Error::QueryReturnedNoRows) => Err(ModelNotFound(format!(
r#"table "{}" {} == {}"#,
M::table_name().into_iden().to_string(),
col.into_iden().to_string(),
value_debug
))),
Err(e) => Err(crate::error::Error::SqlError(e)),
}
}
pub(crate) fn find_optional<'s, M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Option<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
let (sql, params) = Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col).eq(value))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
stmt.query_row(&*params.as_params(), M::from_row)
.optional()
.expect("Failed to run find on DB")
}
pub(crate) fn find_all<'s, M>(&self) -> Result<Vec<M>>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
let (order_by_col, order_by_dir) = M::order_by();
let (sql, params) = Query::select()
.from(M::table_name())
.column(Asterisk)
.order_by(order_by_col, order_by_dir)
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), M::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
pub(crate) fn find_many<'s, M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
limit: Option<u64>,
) -> Result<Vec<M>>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
// TODO: Figure out how to do this conditional builder better
let (order_by_col, order_by_dir) = M::order_by();
let (sql, params) = if let Some(limit) = limit {
Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col).eq(value))
.limit(limit)
.order_by(order_by_col, order_by_dir)
.build_rusqlite(SqliteQueryBuilder)
} else {
Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col).eq(value))
.order_by(order_by_col, order_by_dir)
.build_rusqlite(SqliteQueryBuilder)
};
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), M::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
{
self.upsert_one(
M::table_name(),
M::id_column(),
model.get_id().as_str(),
model.clone().insert_values(source)?,
M::update_columns(),
source,
)
}
fn upsert_one<M>(
&self,
table: impl IntoTableRef,
id_col: impl IntoIden + Eq + Clone,
id_val: &str,
other_values: Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>,
update_columns: Vec<impl IntoIden>,
source: &UpdateSource,
) -> Result<M>
where
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
{
let id_iden = id_col.into_iden();
let mut column_vec = vec![id_iden.clone()];
let mut value_vec =
vec![if id_val == "" { M::generate_id().into() } else { id_val.into() }];
for (col, val) in other_values {
value_vec.push(val.into());
column_vec.push(col.into_iden());
}
let on_conflict = OnConflict::column(id_iden).update_columns(update_columns).to_owned();
let (sql, params) = Query::insert()
.into_table(table)
.columns(column_vec)
.values_panic(value_vec)
.on_conflict(on_conflict)
.returning(Query::returning().exprs(vec![
Expr::col(Asterisk),
Expr::expr(Func::cust("last_insert_rowid")),
Expr::col("rowid"),
]))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
let (m, created): (M, bool) = stmt.query_row(&*params.as_params(), |row| {
M::from_row(row).and_then(|m| {
let rowid: i64 = row.get("rowid")?;
let last_rowid: i64 = row.get("last_insert_rowid()")?;
Ok((m, rowid == last_rowid))
})
})?;
let payload = ModelPayload {
model: m.clone().into(),
update_source: source.clone(),
change: ModelChangeEvent::Upsert { created },
};
if let Err(e) = self.events_tx.send(payload.clone()) {
error!("Failed to send model change {source:?}: {e:?}");
}
Ok(m)
}
pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
let (sql, params) = Query::delete()
.from_table(M::table_name())
.cond_where(Expr::col(M::id_column().into_iden()).eq(m.get_id()))
.build_rusqlite(SqliteQueryBuilder);
self.conn.execute(sql.as_str(), &*params.as_params())?;
let payload = ModelPayload {
model: m.clone().into(),
update_source: source.clone(),
change: ModelChangeEvent::Delete,
};
if let Err(e) = self.events_tx.send(payload) {
error!("Failed to send model change {source:?}: {e:?}");
}
Ok(m.clone())
}
}

View File

@@ -0,0 +1,52 @@
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] r2d2::Error),
#[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("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String),
#[error("unknown error")]
Unknown,
}
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>;

View File

@@ -0,0 +1,96 @@
use crate::blob_manager::{BlobManager, migrate_blob_db};
use crate::error::{Error, Result};
use crate::migrate::migrate_db;
use crate::query_manager::QueryManager;
use crate::util::ModelPayload;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::fs::create_dir_all;
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
pub mod blob_manager;
mod connection_or_tx;
pub mod db_context;
pub mod error;
pub mod migrate;
pub mod models;
pub mod queries;
pub mod query_manager;
pub mod render;
pub mod util;
/// 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();
// Create parent directories if needed
if let Some(parent) = db_path.parent() {
create_dir_all(parent)?;
}
if let Some(parent) = blob_path.parent() {
create_dir_all(parent)?;
}
// Main database pool
let manager = SqliteConnectionManager::file(db_path);
let pool = Pool::builder()
.max_size(100)
.connection_timeout(Duration::from_secs(10))
.build(manager)
.map_err(|e| Error::Database(e.to_string()))?;
migrate_db(&pool)?;
// Blob database pool
let blob_manager = SqliteConnectionManager::file(blob_path);
let blob_pool = Pool::builder()
.max_size(50)
.connection_timeout(Duration::from_secs(10))
.build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
let query_manager = QueryManager::new(pool, tx);
let blob_manager = BlobManager::new(blob_pool);
Ok((query_manager, blob_manager, rx))
}
/// Initialize the database managers with in-memory SQLite databases.
/// Useful for testing and CI environments.
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
// Main database pool
let manager = SqliteConnectionManager::memory();
let pool = Pool::builder()
.max_size(1) // In-memory DB doesn't support multiple connections
.build(manager)
.map_err(|e| Error::Database(e.to_string()))?;
migrate_db(&pool)?;
// Blob database pool
let blob_manager = SqliteConnectionManager::memory();
let blob_pool = Pool::builder()
.max_size(1)
.build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
let query_manager = QueryManager::new(pool, tx);
let blob_manager = BlobManager::new(blob_pool);
Ok((query_manager, blob_manager, rx))
}

View File

@@ -0,0 +1,131 @@
use crate::error::Error::MigrationError;
use crate::error::Result;
use include_dir::{Dir, DirEntry, include_dir};
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use sha2::{Digest, Sha384};
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> 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 mut conn = pool.get()?;
let mut tx = conn.transaction_with_behavior(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 = std::time::Instant::now();
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.elapsed().as_nanos() as i64;
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)
}
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))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
pub enum AnyRequest {
HttpRequest(HttpRequest),
GrpcRequest(GrpcRequest),
WebsocketRequest(WebsocketRequest),
}
impl<'a> DbContext<'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)?))
}
}
}

View File

@@ -0,0 +1,73 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
use crate::util::{BatchUpsertResult, UpdateSource};
use log::info;
impl<'a> DbContext<'a> {
pub fn batch_upsert(
&self,
workspaces: Vec<Workspace>,
environments: Vec<Environment>,
folders: Vec<Folder>,
http_requests: Vec<HttpRequest>,
grpc_requests: Vec<GrpcRequest>,
websocket_requests: Vec<WebsocketRequest>,
source: &UpdateSource,
) -> Result<BatchUpsertResult> {
let mut imported_resources = BatchUpsertResult::default();
if workspaces.len() > 0 {
for v in workspaces {
let x = self.upsert_workspace(&v, source)?;
imported_resources.workspaces.push(x.clone());
}
info!("Upserted {} workspaces", imported_resources.workspaces.len());
}
if http_requests.len() > 0 {
for v in http_requests {
let x = self.upsert_http_request(&v, source)?;
imported_resources.http_requests.push(x.clone());
}
info!("Upserted Imported {} http_requests", imported_resources.http_requests.len());
}
if grpc_requests.len() > 0 {
for v in grpc_requests {
let x = self.upsert_grpc_request(&v, source)?;
imported_resources.grpc_requests.push(x.clone());
}
info!("Upserted {} grpc_requests", imported_resources.grpc_requests.len());
}
if websocket_requests.len() > 0 {
for v in websocket_requests {
let x = self.upsert_websocket_request(&v, source)?;
imported_resources.websocket_requests.push(x.clone());
}
info!("Upserted {} websocket_requests", imported_resources.websocket_requests.len());
}
// Do folders after their children so the UI doesn't render empty folders before populating
// immediately after.
if folders.len() > 0 {
for v in folders {
let x = self.upsert_folder(&v, source)?;
imported_resources.folders.push(x.clone());
}
info!("Upserted {} folders", imported_resources.folders.len());
}
// Do environments last because they can depend on many models (requests, folders, etc)
if environments.len() > 0 {
for x in environments {
let x = self.upsert_environment(&x, source)?;
imported_resources.environments.push(x.clone());
}
info!("Upserted {} environments", imported_resources.environments.len());
}
Ok(imported_resources)
}
}

View File

@@ -0,0 +1,46 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{CookieJar, CookieJarIden};
use crate::util::UpdateSource;
impl<'a> DbContext<'a> {
pub fn get_cookie_jar(&self, id: &str) -> Result<CookieJar> {
self.find_one(CookieJarIden::Id, id)
}
pub fn list_cookie_jars(&self, workspace_id: &str) -> Result<Vec<CookieJar>> {
let mut cookie_jars = self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)?;
if cookie_jars.is_empty() {
let jar = CookieJar {
name: "Default".to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
};
cookie_jars.push(self.upsert_cookie_jar(&jar, &UpdateSource::Background)?);
}
Ok(cookie_jars)
}
pub fn delete_cookie_jar(
&self,
cookie_jar: &CookieJar,
source: &UpdateSource,
) -> Result<CookieJar> {
self.delete(cookie_jar, source)
}
pub fn delete_cookie_jar_by_id(&self, id: &str, source: &UpdateSource) -> Result<CookieJar> {
let cookie_jar = self.get_cookie_jar(id)?;
self.delete_cookie_jar(&cookie_jar, source)
}
pub fn upsert_cookie_jar(
&self,
cookie_jar: &CookieJar,
source: &UpdateSource,
) -> Result<CookieJar> {
self.upsert(cookie_jar, source)
}
}

View File

@@ -0,0 +1,189 @@
use crate::db_context::DbContext;
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result;
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
use crate::util::UpdateSource;
use log::{info, warn};
impl<'a> DbContext<'a> {
pub fn get_environment(&self, id: &str) -> Result<Environment> {
self.find_one(EnvironmentIden::Id, id)
}
pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result<Option<Environment>> {
let mut environments: Vec<Environment> =
self.find_many(EnvironmentIden::ParentId, folder_id, None)?;
// Sort so we return the most recently updated environment
environments.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(environments.get(0).cloned())
}
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
let environments = self.list_environments_ensure_base(workspace_id)?;
let base_environments = environments
.into_iter()
.filter(|e| e.parent_model == "workspace")
.collect::<Vec<Environment>>();
if base_environments.len() > 1 {
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
}
Ok(base_environments.first().cloned().ok_or(
// Should never happen because one should be created above if it does not exist
MissingBaseEnvironment(workspace_id.to_string()),
)?)
}
/// Lists environments and will create a base environment if one doesn't exist
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
let mut environments = self.list_environments_dangerous(workspace_id)?;
let base_environment = environments.iter().find(|e| e.parent_model == "workspace");
if let None = base_environment {
let e = self.upsert_environment(
&Environment {
workspace_id: workspace_id.to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
&UpdateSource::Background,
)?;
info!("Created base environment {} for {workspace_id}", e.id);
environments.push(e);
}
Ok(environments)
}
/// List environments for a workspace. Prefer list_environments_ensure_base()
fn list_environments_dangerous(&self, workspace_id: &str) -> Result<Vec<Environment>> {
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
}
pub fn delete_environment(
&self,
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
let deleted_environment = self.delete(environment, source)?;
// Recreate the base environment if we happened to delete it
self.list_environments_ensure_base(&environment.workspace_id)?;
Ok(deleted_environment)
}
pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {
let environment = self.get_environment(id)?;
self.delete_environment(&environment, source)
}
pub fn duplicate_environment(
&self,
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
let mut environment = environment.clone();
environment.id = "".to_string();
self.upsert_environment(&environment, source)
}
/// Find other environments with the same parent folder
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
if environment.parent_model != "folder" {
return Vec::new();
}
self.list_environments_dangerous(&environment.workspace_id)
.unwrap_or_default()
.into_iter()
.filter(|e| {
e.id != environment.id
&& e.parent_model == "folder"
&& e.parent_id == environment.parent_id
})
.collect()
}
pub fn upsert_environment(
&self,
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
let cleaned_variables = environment
.variables
.iter()
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
.cloned()
.collect::<Vec<EnvironmentVariable>>();
// Sometimes a new environment can be created via sync/import, so we'll just delete
// the others when that happens. Not the best, but it's good for now.
let duplicates = self.list_duplicate_folder_environments(environment);
for duplicate in duplicates {
warn!(
"Deleting duplicate environment {} for folder {:?}",
duplicate.id, environment.parent_id
);
_ = self.delete(&duplicate, source);
}
// Automatically update the environment name based on the folder name
let mut name = environment.name.clone();
match (environment.parent_model.as_str(), environment.parent_id.as_deref()) {
("folder", Some(folder_id)) => {
if let Ok(folder) = self.get_folder(folder_id) {
name = format!("{} Environment", folder.name);
}
}
_ => {}
}
self.upsert(
&Environment { name, variables: cleaned_variables, ..environment.clone() },
source,
)
}
pub fn resolve_environments(
&self,
workspace_id: &str,
folder_id: Option<&str>,
active_environment_id: Option<&str>,
) -> Result<Vec<Environment>> {
let mut environments = Vec::new();
if let Some(folder_id) = folder_id {
let folder = self.get_folder(folder_id)?;
// Add current folder's environment
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
environments.push(e);
};
// Recurse up
let ancestors = self.resolve_environments(
workspace_id,
folder.folder_id.as_deref(),
active_environment_id,
)?;
environments.extend(ancestors);
} else {
// Add active and base environments
if let Some(id) = active_environment_id {
if let Ok(e) = self.get_environment(&id) {
// Add active sub environment
environments.push(e);
};
};
// Add the base environment
environments.push(self.get_base_environment(workspace_id)?);
}
Ok(environments)
}
}

View File

@@ -0,0 +1,144 @@
use crate::connection_or_tx::ConnectionOrTx;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{
Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,
HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_folder(&self, id: &str) -> Result<Folder> {
self.find_one(FolderIden::Id, id)
}
pub fn list_folders(&self, workspace_id: &str) -> Result<Vec<Folder>> {
self.find_many(FolderIden::WorkspaceId, workspace_id, None)
}
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
match self.conn {
ConnectionOrTx::Connection(_) => {}
ConnectionOrTx::Transaction(_) => {}
}
let fid = &folder.id;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.delete_http_request(&m, source)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.delete_websocket_request(&m, source)?;
}
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
self.delete_environment(&e, source)?;
}
// Recurse down into child folders
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
self.delete_folder(&folder, source)?;
}
self.delete(folder, source)
}
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
let folder = self.get_folder(id)?;
self.delete_folder(&folder, source)
}
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
self.upsert(folder, source)
}
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
let fid = &src_folder.id;
let new_folder = self.upsert_folder(
&Folder {
id: "".into(),
sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone()
},
source,
)?;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.upsert_http_request(
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.upsert_websocket_request(
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.upsert_grpc_request(
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
self.upsert_environment(
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
// Recurse down
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
}
Ok(new_folder)
}
pub fn resolve_auth_for_folder(
&self,
folder: &Folder,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = folder.authentication_type.clone() {
return Ok((Some(at), folder.authentication.clone(), folder.id.clone()));
}
if let Some(folder_id) = folder.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&folder.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_folder(&self, folder: &Folder) -> Result<Vec<HttpRequestHeader>> {
let mut headers = Vec::new();
if let Some(folder_id) = folder.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
// NOTE: Add parent headers first, so overrides are logical
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&folder.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
headers.append(&mut folder.headers.clone());
Ok(headers)
}
}

View File

@@ -0,0 +1,51 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
use crate::util::UpdateSource;
use chrono::{Duration, Utc};
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> DbContext<'a> {
pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {
self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)
}
pub fn upsert_graphql_introspection(
&self,
workspace_id: &str,
request_id: &str,
content: Option<String>,
source: &UpdateSource,
) -> Result<GraphQlIntrospection> {
// Clean up old ones every time a new one is upserted
self.delete_expired_graphql_introspections()?;
match self.get_graphql_introspection(request_id) {
None => self.upsert(
&GraphQlIntrospection {
content,
request_id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
source,
),
Some(introspection) => {
self.upsert(&GraphQlIntrospection { content, ..introspection }, source)
}
}
}
pub fn delete_expired_graphql_introspections(&self) -> Result<()> {
let cutoff = Utc::now().naive_utc() - Duration::days(7);
let (sql, params) = Query::delete()
.from_table(GraphQlIntrospectionIden::Table)
.cond_where(Expr::col(GraphQlIntrospectionIden::UpdatedAt).lt(cutoff))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
}

View File

@@ -0,0 +1,94 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> DbContext<'a> {
pub fn get_grpc_connection(&self, id: &str) -> Result<GrpcConnection> {
self.find_one(GrpcConnectionIden::Id, id)
}
pub fn delete_all_grpc_connections_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_grpc_connections_for_request(request_id, None)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_all_grpc_connections_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<()> {
for m in self.list_grpc_connections(workspace_id)? {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_grpc_connection(
&self,
m: &GrpcConnection,
source: &UpdateSource,
) -> Result<GrpcConnection> {
self.delete(m, source)
}
pub fn delete_grpc_connection_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<GrpcConnection> {
let grpc_connection = self.get_grpc_connection(id)?;
self.delete_grpc_connection(&grpc_connection, source)
}
pub fn list_grpc_connections_for_request(
&self,
request_id: &str,
limit: Option<u64>,
) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
}
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
}
pub fn cancel_pending_grpc_connections(&self) -> Result<()> {
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
let (sql, params) = Query::update()
.table(GrpcConnectionIden::Table)
.values([(GrpcConnectionIden::State, closed.as_str().into())])
.cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
pub fn upsert_grpc_connection(
&self,
grpc_connection: &GrpcConnection,
source: &UpdateSource,
) -> Result<GrpcConnection> {
let connections =
self.list_grpc_connections_for_request(grpc_connection.request_id.as_str(), None)?;
for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) {
debug!("Deleting old gRPC connection {}", grpc_connection.id);
self.delete_grpc_connection(&m, source)?;
}
self.upsert(grpc_connection, source)
}
}

View File

@@ -0,0 +1,22 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GrpcEvent, GrpcEventIden};
use crate::util::UpdateSource;
impl<'a> DbContext<'a> {
pub fn get_grpc_events(&self, id: &str) -> Result<GrpcEvent> {
self.find_one(GrpcEventIden::Id, id)
}
pub fn list_grpc_events(&self, connection_id: &str) -> Result<Vec<GrpcEvent>> {
self.find_many(GrpcEventIden::ConnectionId, connection_id, None)
}
pub fn upsert_grpc_event(
&self,
grpc_event: &GrpcEvent,
source: &UpdateSource,
) -> Result<GrpcEvent> {
self.upsert(grpc_event, source)
}
}

View File

@@ -0,0 +1,92 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_grpc_request(&self, id: &str) -> Result<GrpcRequest> {
self.find_one(GrpcRequestIden::Id, id)
}
pub fn list_grpc_requests(&self, workspace_id: &str) -> Result<Vec<GrpcRequest>> {
self.find_many(GrpcRequestIden::WorkspaceId, workspace_id, None)
}
pub fn delete_grpc_request(
&self,
m: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_grpc_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let request = self.get_grpc_request(id)?;
self.delete_grpc_request(&request, source)
}
pub fn duplicate_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let mut request = grpc_request.clone();
request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001;
self.upsert(&request, source)
}
pub fn upsert_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.upsert(grpc_request, source)
}
pub fn resolve_auth_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = grpc_request.authentication_type.clone() {
return Ok((Some(at), grpc_request.authentication.clone(), grpc_request.id.clone()));
}
if let Some(folder_id) = grpc_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_metadata_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut metadata = Vec::new();
if let Some(folder_id) = grpc_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
metadata.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
let mut workspace_metadata = self.resolve_headers_for_workspace(&workspace);
metadata.append(&mut workspace_metadata);
}
metadata.append(&mut grpc_request.metadata.clone());
Ok(metadata)
}
}

View File

@@ -0,0 +1,106 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_http_request(&self, id: &str) -> Result<HttpRequest> {
self.find_one(HttpRequestIden::Id, id)
}
pub fn list_http_requests(&self, workspace_id: &str) -> Result<Vec<HttpRequest>> {
self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None)
}
pub fn delete_http_request(
&self,
m: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_http_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<HttpRequest> {
let http_request = self.get_http_request(id)?;
self.delete_http_request(&http_request, source)
}
pub fn duplicate_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
let mut http_request = http_request.clone();
http_request.id = "".to_string();
http_request.sort_priority = http_request.sort_priority + 0.001;
self.upsert(&http_request, source)
}
pub fn upsert_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.upsert(http_request, source)
}
pub fn resolve_auth_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = http_request.authentication_type.clone() {
return Ok((Some(at), http_request.authentication.clone(), http_request.id.clone()));
}
if let Some(folder_id) = http_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&http_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
if let Some(folder_id) = http_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&http_request.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
headers.append(&mut http_request.headers.clone());
Ok(headers)
}
pub fn list_http_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<HttpRequest>> {
let mut children = Vec::new();
for m in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_http_requests_for_folder_recursive(&m.id)?);
}
for m in self.find_many::<HttpRequest>(FolderIden::FolderId, folder_id, None)? {
children.push(m);
}
Ok(children)
}
}

View File

@@ -0,0 +1,18 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{HttpResponseEvent, HttpResponseEventIden};
use crate::util::UpdateSource;
impl<'a> DbContext<'a> {
pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {
self.find_many(HttpResponseEventIden::ResponseId, response_id, None)
}
pub fn upsert_http_response_event(
&self,
http_response_event: &HttpResponseEvent,
source: &UpdateSource,
) -> Result<HttpResponseEvent> {
self.upsert(http_response_event, source)
}
}

View File

@@ -0,0 +1,116 @@
use crate::blob_manager::BlobManager;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::{debug, error};
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::fs;
impl<'a> DbContext<'a> {
pub fn get_http_response(&self, id: &str) -> Result<HttpResponse> {
self.find_one(HttpResponseIden::Id, id)
}
pub fn list_http_responses_for_request(
&self,
request_id: &str,
limit: Option<u64>,
) -> Result<Vec<HttpResponse>> {
self.find_many(HttpResponseIden::RequestId, request_id, limit)
}
pub fn list_http_responses(
&self,
workspace_id: &str,
limit: Option<u64>,
) -> Result<Vec<HttpResponse>> {
self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)
}
pub fn delete_all_http_responses_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_http_responses_for_request(request_id, None)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_all_http_responses_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses =
self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, workspace_id, None)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_http_response(
&self,
http_response: &HttpResponse,
source: &UpdateSource,
blob_manager: &BlobManager,
) -> Result<HttpResponse> {
// Delete the body file if it exists
if let Some(p) = http_response.body_path.clone() {
if let Err(e) = fs::remove_file(p) {
error!("Failed to delete body file: {}", e);
};
}
// Delete request body blobs (pattern: {response_id}.request)
let blob_ctx = blob_manager.connect();
let body_id = format!("{}.request", http_response.id);
if let Err(e) = blob_ctx.delete_chunks(&body_id) {
error!("Failed to delete request body blobs: {}", e);
}
Ok(self.delete(http_response, source)?)
}
pub fn upsert_http_response(
&self,
http_response: &HttpResponse,
source: &UpdateSource,
blob_manager: &BlobManager,
) -> Result<HttpResponse> {
let responses = self.list_http_responses_for_request(&http_response.request_id, None)?;
for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) {
debug!("Deleting old HTTP response {}", http_response.id);
self.delete_http_response(&m, source, blob_manager)?;
}
self.upsert(http_response, source)
}
pub fn cancel_pending_http_responses(&self) -> Result<()> {
let closed = serde_json::to_value(&HttpResponseState::Closed)?;
let (sql, params) = Query::update()
.table(HttpResponseIden::Table)
.values([(HttpResponseIden::State, closed.as_str().into())])
.cond_where(Expr::col(HttpResponseIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
pub fn update_http_response_if_id(
&self,
response: &HttpResponse,
source: &UpdateSource,
) -> Result<HttpResponse> {
if response.id.is_empty() { Ok(response.clone()) } else { self.upsert(response, source) }
}
}

View File

@@ -0,0 +1,176 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
use crate::util::UpdateSource;
use chrono::NaiveDateTime;
use log::error;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> DbContext<'a> {
pub fn list_key_values(&self) -> Result<Vec<KeyValue>> {
let (sql, params) = Query::select()
.from(KeyValueIden::Table)
.column(Asterisk)
// Temporary clause to prevent bug when reverting to the previous version, before the
// ID column was added. A previous version will not know about ID and will create
// key/value entries that don't have one. This clause ensures they are not queried
// TODO: Add migration to delete key/values with NULL IDs later on, then remove this
.cond_where(Expr::col(KeyValueIden::Id).is_not_null())
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), KeyValue::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
pub fn get_key_value_str(&self, namespace: &str, key: &str, default: &str) -> String {
match self.get_key_value_raw(namespace, key) {
None => default.to_string(),
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse string key value: {}", e);
default.to_string()
}
}
}
}
}
pub fn get_key_value_dte(
&self,
namespace: &str,
key: &str,
default: NaiveDateTime,
) -> NaiveDateTime {
match self.get_key_value_raw(namespace, key) {
None => default,
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse date key value: {}", e);
default
}
}
}
}
}
pub fn get_key_value_int(&self, namespace: &str, key: &str, default: i32) -> i32 {
match self.get_key_value_raw(namespace, key) {
None => default.clone(),
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse int key value: {}", e);
default.clone()
}
}
}
}
}
pub fn get_key_value_raw(&self, namespace: &str, key: &str) -> Option<KeyValue> {
let (sql, params) = Query::select()
.from(KeyValueIden::Table)
.column(Asterisk)
.cond_where(
Cond::all()
.add(Expr::col(KeyValueIden::Namespace).eq(namespace))
.add(Expr::col(KeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn.resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok()
}
pub fn set_key_value_dte(
&self,
namespace: &str,
key: &str,
value: NaiveDateTime,
source: &UpdateSource,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value).unwrap();
self.set_key_value_raw(namespace, key, &encoded, source)
}
pub fn set_key_value_str(
&self,
namespace: &str,
key: &str,
value: &str,
source: &UpdateSource,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value).unwrap();
self.set_key_value_raw(namespace, key, &encoded, source)
}
pub fn set_key_value_int(
&self,
namespace: &str,
key: &str,
value: i32,
source: &UpdateSource,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value).unwrap();
self.set_key_value_raw(namespace, key, &encoded, source)
}
pub fn set_key_value_raw(
&self,
namespace: &str,
key: &str,
value: &str,
source: &UpdateSource,
) -> (KeyValue, bool) {
match self.get_key_value_raw(namespace, key) {
None => (
self.upsert_key_value(
&KeyValue {
namespace: namespace.to_string(),
key: key.to_string(),
value: value.to_string(),
..Default::default()
},
source,
)
.expect("Failed to create key value"),
true,
),
Some(kv) => (
self.upsert_key_value(&KeyValue { value: value.to_string(), ..kv }, source)
.expect("Failed to update key value"),
false,
),
}
}
pub fn upsert_key_value(
&self,
key_value: &KeyValue,
source: &UpdateSource,
) -> Result<KeyValue> {
self.upsert(key_value, source)
}
pub fn delete_key_value(
&self,
namespace: &str,
key: &str,
source: &UpdateSource,
) -> Result<()> {
let kv = match self.get_key_value_raw(namespace, key) {
None => return Ok(()),
Some(m) => m,
};
self.delete(&kv, source)?;
Ok(())
}
}

View File

@@ -0,0 +1,24 @@
pub mod any_request;
mod batch;
mod cookie_jars;
mod environments;
mod folders;
mod graphql_introspections;
mod grpc_connections;
mod grpc_events;
mod grpc_requests;
mod http_requests;
mod http_response_events;
mod http_responses;
mod key_values;
mod plugin_key_values;
mod plugins;
mod settings;
mod sync_states;
mod websocket_connections;
mod websocket_events;
mod websocket_requests;
mod workspace_metas;
mod workspaces;
const MAX_HISTORY_ITEMS: usize = 20;

View File

@@ -0,0 +1,79 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{PluginKeyValue, PluginKeyValueIden};
use sea_query::Keyword::CurrentTimestamp;
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> DbContext<'a> {
pub fn get_plugin_key_value(&self, plugin_name: &str, key: &str) -> Option<PluginKeyValue> {
let (sql, params) = Query::select()
.from(PluginKeyValueIden::Table)
.column(Asterisk)
.cond_where(
Cond::all()
.add(Expr::col(PluginKeyValueIden::PluginName).eq(plugin_name))
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn.resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()
}
pub fn set_plugin_key_value(
&self,
plugin_name: &str,
key: &str,
value: &str,
) -> (PluginKeyValue, bool) {
let existing = self.get_plugin_key_value(plugin_name, key);
let (sql, params) = Query::insert()
.into_table(PluginKeyValueIden::Table)
.columns([
PluginKeyValueIden::CreatedAt,
PluginKeyValueIden::UpdatedAt,
PluginKeyValueIden::PluginName,
PluginKeyValueIden::Key,
PluginKeyValueIden::Value,
])
.values_panic([
CurrentTimestamp.into(),
CurrentTimestamp.into(),
plugin_name.into(),
key.into(),
value.into(),
])
.on_conflict(
OnConflict::new()
.update_columns([PluginKeyValueIden::UpdatedAt, PluginKeyValueIden::Value])
.to_owned(),
)
.returning_all()
.build_rusqlite(SqliteQueryBuilder);
let mut stmt =
self.conn.prepare(sql.as_str()).expect("Failed to prepare PluginKeyValue upsert");
let m: PluginKeyValue = stmt
.query_row(&*params.as_params(), |row| row.try_into())
.expect("Failed to upsert KeyValue");
(m, existing.is_none())
}
pub fn delete_plugin_key_value(&self, namespace: &str, key: &str) -> Result<bool> {
if let None = self.get_plugin_key_value(namespace, key) {
return Ok(false);
};
let (sql, params) = Query::delete()
.from_table(PluginKeyValueIden::Table)
.cond_where(
Cond::all()
.add(Expr::col(PluginKeyValueIden::PluginName).eq(namespace))
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn.execute(sql.as_str(), &*params.as_params())?;
Ok(true)
}
}

View File

@@ -0,0 +1,31 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{Plugin, PluginIden};
use crate::util::UpdateSource;
impl<'a> DbContext<'a> {
pub fn get_plugin(&self, id: &str) -> Result<Plugin> {
self.find_one(PluginIden::Id, id)
}
pub fn get_plugin_by_directory(&self, directory: &str) -> Option<Plugin> {
self.find_optional(PluginIden::Directory, directory)
}
pub fn list_plugins(&self) -> Result<Vec<Plugin>> {
self.find_all()
}
pub fn delete_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
self.delete(plugin, source)
}
pub fn delete_plugin_by_id(&self, id: &str, source: &UpdateSource) -> Result<Plugin> {
let plugin = self.get_plugin(id)?;
self.delete_plugin(&plugin, source)
}
pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
self.upsert(plugin, source)
}
}

View File

@@ -0,0 +1,51 @@
use std::collections::HashMap;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{EditorKeymap, Settings, SettingsIden};
use crate::util::UpdateSource;
impl<'a> DbContext<'a> {
pub fn get_settings(&self) -> Settings {
let id = "default".to_string();
if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, &id) {
return s;
};
let settings = Settings {
model: "settings".to_string(),
id,
created_at: Default::default(),
updated_at: Default::default(),
appearance: "system".to_string(),
client_certificates: Vec::new(),
editor_font_size: 12,
editor_font: None,
editor_keymap: EditorKeymap::Default,
editor_soft_wrap: true,
interface_font_size: 14,
interface_scale: 1.0,
interface_font: None,
hide_window_controls: false,
use_native_titlebar: false,
open_workspace_new_window: None,
proxy: None,
theme_dark: "yaak-dark".to_string(),
theme_light: "yaak-light".to_string(),
update_channel: "stable".to_string(),
autoupdate: true,
colored_methods: false,
hide_license_badge: false,
auto_download_updates: true,
check_notifications: true,
hotkeys: HashMap::new(),
};
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
}
pub fn upsert_settings(&self, settings: &Settings, source: &UpdateSource) -> Result<Settings> {
self.upsert(settings, source)
}
}

View File

@@ -0,0 +1,45 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
use crate::util::UpdateSource;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::path::Path;
impl<'a> DbContext<'a> {
pub fn get_sync_state(&self, id: &str) -> Result<SyncState> {
self.find_one(SyncStateIden::Id, id)
}
pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
self.upsert(sync_state, &UpdateSource::Sync)
}
pub fn list_sync_states_for_workspace(
&self,
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<SyncState>> {
let (sql, params) = Query::select()
.from(SyncStateIden::Table)
.column(Asterisk)
.cond_where(
Cond::all()
.add(Expr::col(SyncStateIden::WorkspaceId).eq(workspace_id))
.add(Expr::col(SyncStateIden::SyncDir).eq(sync_dir.to_string_lossy())),
)
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), SyncState::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
pub fn delete_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
self.delete(sync_state, &UpdateSource::Sync)
}
pub fn delete_sync_state_by_id(&self, id: &str) -> Result<SyncState> {
let sync_state = self.get_sync_state(id)?;
self.delete_sync_state(&sync_state)
}
}

View File

@@ -0,0 +1,97 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> DbContext<'a> {
pub fn get_websocket_connection(&self, id: &str) -> Result<WebsocketConnection> {
self.find_one(WebsocketConnectionIden::Id, id)
}
pub fn delete_all_websocket_connections_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_websocket_connections_for_request(request_id)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_all_websocket_connections_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_websocket_connections(workspace_id)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn list_websocket_connections(
&self,
workspace_id: &str,
) -> Result<Vec<WebsocketConnection>> {
self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)
}
pub fn list_websocket_connections_for_request(
&self,
request_id: &str,
) -> Result<Vec<WebsocketConnection>> {
self.find_many(WebsocketConnectionIden::RequestId, request_id, None)
}
pub fn delete_websocket_connection(
&self,
websocket_connection: &WebsocketConnection,
source: &UpdateSource,
) -> Result<WebsocketConnection> {
self.delete(websocket_connection, source)
}
pub fn delete_websocket_connection_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<WebsocketConnection> {
let websocket_connection = self.get_websocket_connection(id)?;
self.delete_websocket_connection(&websocket_connection, source)
}
pub fn upsert_websocket_connection(
&self,
websocket_connection: &WebsocketConnection,
source: &UpdateSource,
) -> Result<WebsocketConnection> {
let connections =
self.list_websocket_connections_for_request(&websocket_connection.request_id)?;
for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) {
debug!("Deleting old websocket connection {}", websocket_connection.id);
self.delete_websocket_connection(&m, source)?;
}
self.upsert(websocket_connection, source)
}
pub fn cancel_pending_websocket_connections(&self) -> Result<()> {
let closed = serde_json::to_value(&WebsocketConnectionState::Closed)?;
let (sql, params) = Query::update()
.table(WebsocketConnectionIden::Table)
.values([(WebsocketConnectionIden::State, closed.as_str().into())])
.cond_where(Expr::col(WebsocketConnectionIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
}

View File

@@ -0,0 +1,22 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{WebsocketEvent, WebsocketEventIden};
use crate::util::UpdateSource;
impl<'a> DbContext<'a> {
pub fn get_websocket_event(&self, id: &str) -> Result<WebsocketEvent> {
self.find_one(WebsocketEventIden::Id, id)
}
pub fn list_websocket_events(&self, connection_id: &str) -> Result<Vec<WebsocketEvent>> {
self.find_many(WebsocketEventIden::ConnectionId, connection_id, None)
}
pub fn upsert_websocket_event(
&self,
websocket_event: &WebsocketEvent,
source: &UpdateSource,
) -> Result<WebsocketEvent> {
self.upsert(websocket_event, source)
}
}

View File

@@ -0,0 +1,100 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_websocket_request(&self, id: &str) -> Result<WebsocketRequest> {
self.find_one(WebsocketRequestIden::Id, id)
}
pub fn list_websocket_requests(&self, workspace_id: &str) -> Result<Vec<WebsocketRequest>> {
self.find_many(WebsocketRequestIden::WorkspaceId, workspace_id, None)
}
pub fn delete_websocket_request(
&self,
websocket_request: &WebsocketRequest,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
self.delete(websocket_request, source)
}
pub fn delete_websocket_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
let request = self.get_websocket_request(id)?;
self.delete_websocket_request(&request, source)
}
pub fn duplicate_websocket_request(
&self,
websocket_request: &WebsocketRequest,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
let mut websocket_request = websocket_request.clone();
websocket_request.id = "".to_string();
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
self.upsert(&websocket_request, source)
}
pub fn upsert_websocket_request(
&self,
websocket_request: &WebsocketRequest,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
self.upsert(websocket_request, source)
}
pub fn resolve_auth_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = websocket_request.authentication_type.clone() {
return Ok((
Some(at),
websocket_request.authentication.clone(),
websocket_request.id.clone(),
));
}
if let Some(folder_id) = websocket_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<Vec<HttpRequestHeader>> {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
headers.append(&mut workspace.headers.clone());
if let Some(folder_id) = websocket_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
headers.append(&mut websocket_request.headers.clone());
Ok(headers)
}
}

View File

@@ -0,0 +1,45 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
use crate::util::UpdateSource;
use log::info;
impl<'a> DbContext<'a> {
pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {
self.find_optional(WorkspaceMetaIden::WorkspaceId, workspace_id)
}
pub fn list_workspace_metas(&self, workspace_id: &str) -> Result<Vec<WorkspaceMeta>> {
let mut workspace_metas =
self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)?;
if workspace_metas.is_empty() {
let wm = WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };
workspace_metas.push(self.upsert_workspace_meta(&wm, &UpdateSource::Background)?)
}
Ok(workspace_metas)
}
pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
let workspace_meta = self.get_workspace_meta(workspace_id);
if let Some(workspace_meta) = workspace_meta {
return Ok(workspace_meta);
}
let workspace_meta =
WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };
info!("Creating WorkspaceMeta for {workspace_id}");
self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)
}
pub fn upsert_workspace_meta(
&self,
workspace_meta: &WorkspaceMeta,
source: &UpdateSource,
) -> Result<WorkspaceMeta> {
self.upsert(workspace_meta, source)
}
}

View File

@@ -0,0 +1,85 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{
EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
WebsocketRequestIden, Workspace, WorkspaceIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_workspace(&self, id: &str) -> Result<Workspace> {
self.find_one(WorkspaceIden::Id, id)
}
pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {
let mut workspaces = self.find_all()?;
if workspaces.is_empty() {
workspaces.push(self.upsert_workspace(
&Workspace {
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Background,
)?)
}
Ok(workspaces)
}
pub fn delete_workspace(
&self,
workspace: &Workspace,
source: &UpdateSource,
) -> Result<Workspace> {
for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_http_request(&m, source)?;
}
for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? {
self.delete_websocket_request(&m, source)?;
}
for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? {
self.delete_folder(&m, source)?;
}
for m in self.find_many(EnvironmentIden::WorkspaceId, &workspace.id, None)? {
self.delete_environment(&m, source)?;
}
self.delete(workspace, source)
}
pub fn delete_workspace_by_id(&self, id: &str, source: &UpdateSource) -> Result<Workspace> {
let workspace = self.get_workspace(id)?;
self.delete_workspace(&workspace, source)
}
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
self.upsert(w, source)
}
pub fn resolve_auth_for_workspace(
&self,
workspace: &Workspace,
) -> (Option<String>, BTreeMap<String, Value>, String) {
(
workspace.authentication_type.clone(),
workspace.authentication.clone(),
workspace.id.clone(),
)
}
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
workspace.headers.clone()
}
}

Some files were not shown because too many files have changed in this diff Show More