v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)

* [P1-T1] Change::Ddl Rust variant + execute_change arm

* [P1-T2] Frontend ddl change type + objectCrud capability

* [P1-T3] use_keychain data model + migration v8

* [P2-T1] object_crud skeleton + validators + build_ddl dispatch

* [P2-T2] Sequence builders

* [P2-T3] Enum builders (no value removal)

* [P2-T4] View / matview / extension builders

* [P2-T5] Index + constraint builders

* [P2-T6] Function / procedure + trigger builders

* [P2-T7] build_object_ddl + get_available_extensions commands + wrappers

* [P3-T1] conditional keychain + session passwords

* [P3-T2] keychain-off password prompt on connect

* [P3-T3] default-ON keychain opt-out + tooltip + modal conditional

* [P3-T4] ddl queue card + after-commit refetch

* [P4-T1] ObjectCrudDialog shell

* [P4-T2] SequenceForm

* [P4-T3] EnumForm with no-removal note

* [P4-T4] ExtensionForm with available-extensions picker

* [P4-T5] ViewForm (view + materialized view)

* [P5-T1] IndexForm with column picker

* [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker

* [P5-T3] FunctionForm (function + procedure)

* [P5-T4] TriggerForm with trigger-function picker

* [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring

* [P6-T1] object tab type + openObjectTab dedup

* [P6-T2] extract ObjectDetail for object tabs

* [P6-T3] Objects view two-pane sidebar + workspace

* [P6-T4] object-tab content + per-type tab icons

* [P7-T1] Version bump 0.7.5 -> 0.7.6

* [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6

* [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6

* fix(ui): object tab icon stacks above name (preflight svg block)

* fix(ui): optically center object tab icon with name

* fix(ui): object tab icon matches query/table icon handling

* [UI-POLISH-1] objectForm tab type + openFormTab store action

* [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle

* [UI-POLISH-3] route create/edit through form tabs; remove modal

* docs: create/edit now open as form tabs (AGENTS sync)

* [FB-1] follow app styling patterns + schema dropdown in forms

* [FB-2] Monaco editor for function body + view definition

* [FB-3] form tabs styled like viewers + in-cell editing

* [FB-5] focus outline scoped to input area (label excluded)

* [FB-6] no amber focus outline on Monaco body/definition rows

* [FB-7] header dedupe + schema default + full edit prefill

* [FB-8] SQL view in read-only Monaco editor

* docs: roadmap — table create/edit + relationships (next)

* docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
This commit is contained in:
2026-08-06 22:56:49 +08:00
committed by GitHub
parent 8d8ed78202
commit 0c74d77e75
86 changed files with 7094 additions and 1165 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.5)", () => {
describe("tauri bundle config (v0.7.6)", () => {
it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
});
it("version is 0.7.5", () => {
expect(tauriConf.version).toBe("0.7.5");
it("version is 0.7.6", () => {
expect(tauriConf.version).toBe("0.7.6");
});
});
+16
View File
@@ -61,4 +61,20 @@ describe("buildChangeSql", () => {
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
});
});
const ddlItem: QueueItem = {
id: "ch-1", type: "ddl",
sql: "CREATE TYPE public.role AS ENUM ('admin')",
status: "pending", createdAt: 0,
};
describe("ddl change", () => {
it("builds a ddl payload with id + type + sql", () => {
expect(buildChangePayload(ddlItem)).toEqual({
id: "ch-1", type: "ddl", sql: "CREATE TYPE public.role AS ENUM ('admin')",
});
});
it("preview SQL is the raw sql", () => {
expect(buildChangeSql(ddlItem)).toBe("CREATE TYPE public.role AS ENUM ('admin')");
});
});
+4
View File
@@ -57,6 +57,8 @@ export function buildChangeSql(item: QueueItem): string {
}
case "empty_table":
return `DELETE FROM ${t}`;
case "ddl":
return item.sql ?? "";
case "drop_table":
return `DROP TABLE ${t}`;
default:
@@ -84,6 +86,8 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
return { id: item.id, type: "drop_table", schema, table };
case "empty_table":
return { id: item.id, type: "empty_table", schema, table };
case "ddl":
return { id: item.id, type: "ddl", sql: item.sql };
default:
return { id: item.id, type: item.type, sql: item.sql };
}
+14 -2
View File
@@ -6,7 +6,7 @@ describe("dbCapabilities", () => {
const c = DB_CAPABILITIES.postgresql;
expect(c).toEqual<DbCapabilities>({
explorer: true, queries: true, objects: true, visualizer: true,
tools: true, editing: true, import: true, ddl: true,
tools: true, editing: true, import: true, ddl: true, objectCrud: true,
});
});
@@ -37,7 +37,7 @@ describe("dbCapabilities", () => {
it("gives Redis nothing (connection+test only)", () => {
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
explorer: false, queries: false, objects: false, visualizer: false,
tools: false, editing: false, import: false, ddl: false,
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
});
});
@@ -57,4 +57,16 @@ describe("dbCapabilities", () => {
expect(getCapabilities("sqlite").objects).toBe(false);
expect(getCapabilities("redis").objects).toBe(false);
});
});
describe("objectCrud capability", () => {
it("is true only for postgresql", () => {
expect(DB_CAPABILITIES.postgresql.objectCrud).toBe(true);
expect(DB_CAPABILITIES.mysql.objectCrud).toBe(false);
expect(DB_CAPABILITIES.sqlite.objectCrud).toBe(false);
expect(DB_CAPABILITIES.redis.objectCrud).toBe(false);
});
it("unknown types get objectCrud false", () => {
expect(getCapabilities("oracle").objectCrud).toBe(false);
});
});
+4 -2
View File
@@ -17,15 +17,17 @@ export interface DbCapabilities {
import: boolean;
/** Copy table schema (DDL). */
ddl: boolean;
/** Create / edit / drop PostgreSQL objects via the changes queue. */
objectCrud: boolean;
}
const ALL_FALSE: DbCapabilities = {
explorer: false, queries: false, objects: false, visualizer: false,
tools: false, editing: false, import: false, ddl: false,
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
};
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true },
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true },
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
redis: { ...ALL_FALSE },
+17 -3
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw";
describe("v0.7.5 docs coverage", () => {
describe("v0.7.6 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,8 @@ describe("v0.7.5 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.7.5", () => {
expect(readme).toContain("0.7.5");
it("README declares v0.7.6", () => {
expect(readme).toContain("0.7.6");
});
it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -50,4 +50,18 @@ describe("v0.7.5 docs coverage", () => {
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
});
it("AGENTS.md marks object management CRUD complete", () => {
expect(agents).toMatch(/Object management CRUD \| ✅/);
});
it("AGENTS.md marks the keychain toggle complete", () => {
expect(agents).toMatch(/Enable keychain toggle \| ✅/);
});
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
});
it("README links to v0.7.6 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.6/");
expect(readme).toContain("Gridline_0.7.6_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.6-1.x86_64.rpm");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { saveConnectionPassword, deleteConnectionPassword } from "./commands";
// Persist (or purge) the DB password according to the keychain toggle.
// use_keychain defaults to true (opt-out): ON = OS keychain; OFF = purge + session-only.
// Shared by connectionStore.createConnection and EditConnectionModal.
export async function persistDbPassword(
connectionId: string,
useKeychain: boolean | undefined,
password: string | null | undefined,
): Promise<void> {
const useKc = useKeychain ?? true;
if (useKc && password) {
await saveConnectionPassword(connectionId, password);
} else if (!useKc) {
try { await deleteConnectionPassword(connectionId); } catch { /* purge; ignore missing */ }
}
}
+463
View File
@@ -0,0 +1,463 @@
import { describe, it, expect, vi, afterEach } from "vitest";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
import { invoke } from "@tauri-apps/api/core";
import {
buildObjectDdl,
dropCrudParams,
getAvailableExtensions,
initialCrudParams,
} from "./objectCrud";
describe("buildObjectDdl", () => {
afterEach(() => vi.restoreAllMocks());
it("invokes build_object_ddl with connectionId, kind, params", async () => {
vi.mocked(invoke).mockResolvedValueOnce([
"CREATE TYPE public.role AS ENUM ('admin')",
]);
const sql = await buildObjectDdl("c1", "enum", {
schema: "public",
name: "role",
action: { op: "create", labels: ["admin"] },
});
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
connectionId: "c1",
kind: "enum",
params: {
schema: "public",
name: "role",
action: { op: "create", labels: ["admin"] },
},
});
expect(sql).toEqual(["CREATE TYPE public.role AS ENUM ('admin')"]);
});
it("passes through drop params for sequences", async () => {
vi.mocked(invoke).mockResolvedValueOnce(['DROP SEQUENCE "public"."s"']);
const sql = await buildObjectDdl("c1", "sequence", {
schema: "public",
name: "s",
action: { op: "drop" },
});
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
connectionId: "c1",
kind: "sequence",
params: { schema: "public", name: "s", action: { op: "drop" } },
});
expect(sql).toEqual(['DROP SEQUENCE "public"."s"']);
});
});
describe("getAvailableExtensions", () => {
afterEach(() => vi.restoreAllMocks());
it("invokes get_available_extensions with connectionId", async () => {
vi.mocked(invoke).mockResolvedValueOnce([
{ name: "pgcrypto", version: "1.3", comment: null },
]);
const exts = await getAvailableExtensions("c1");
expect(invoke).toHaveBeenCalledWith("get_available_extensions", {
connectionId: "c1",
});
expect(exts).toEqual([{ name: "pgcrypto", version: "1.3", comment: null }]);
});
it("returns empty array when no extensions available", async () => {
vi.mocked(invoke).mockResolvedValueOnce([]);
const exts = await getAvailableExtensions("c2");
expect(exts).toEqual([]);
});
});
describe("initialCrudParams", () => {
it("create shape starts empty with sensible defaults", () => {
const p = initialCrudParams(
"sequence",
{ schema: "public", name: "" },
"create",
);
expect(p).toEqual({
schema: "public",
name: "",
action: {
op: "create",
increment: "1",
min_value: "1",
max_value: "9223372036854775807",
start: "1",
cycle: false,
},
});
});
it("edit prefills the name from the item", () => {
const p = initialCrudParams(
"sequence",
{ schema: "public", name: "s" },
"edit",
);
expect(p).toMatchObject({
schema: "public",
name: "s",
action: { op: "create", increment: "1" },
});
});
it("enum edit carries labels through", () => {
const p = initialCrudParams(
"enum",
{ schema: "public", name: "role", labels: ["admin"] },
"edit",
);
expect(p.action).toMatchObject({ op: "create", labels: ["admin"] });
});
it("index create keeps the owning table and an empty column list", () => {
const p = initialCrudParams(
"index",
{ schema: "public", table: "users", name: "" },
"create",
);
expect(p).toEqual({
schema: "public",
table: "users",
name: "",
action: { op: "create", unique: false, method: "", columns: [], predicate: null },
});
});
it("constraint create starts as a CHECK on the owning table", () => {
const p = initialCrudParams(
"constraint",
{ schema: "public", table: "users", name: "" },
"create",
);
expect(p).toEqual({
schema: "public",
table: "users",
name: "",
action: { op: "check", expression: "" },
});
});
it("procedure edit prefills is_procedure from kind", () => {
const p = initialCrudParams(
"procedure",
{ schema: "public", name: "do_thing" },
"edit",
);
expect(p).toMatchObject({
schema: "public",
name: "do_thing",
is_procedure: true,
action: { op: "create_or_replace", args: [] },
});
});
it("view edit prefills the definition and marks non-materialized", () => {
const p = initialCrudParams(
"view",
{ schema: "public", name: "v", definition: "SELECT 1" },
"edit",
);
expect(p).toMatchObject({
schema: "public",
name: "v",
materialized: false,
action: { op: "create", definition: "SELECT 1" },
});
});
it("function edit prefills body, zipped args, return_type, and language", () => {
const p = initialCrudParams(
"function",
{
schema: "public",
name: "add_one",
return_type: "integer",
argument_types: ["integer"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "plpgsql",
source: "BEGIN RETURN x + 1; END",
kind: "f",
},
"edit",
);
expect(p).toMatchObject({
schema: "public",
name: "add_one",
is_procedure: false,
action: {
op: "create_or_replace",
args: [{ mode: "IN", name: "x", type: "integer" }],
return_type: "integer",
language: "plpgsql",
body: "BEGIN RETURN x + 1; END",
},
});
});
it("procedure edit prefills source and zips multiple args", () => {
const p = initialCrudParams(
"procedure",
{
schema: "public",
name: "do_thing",
return_type: "void",
argument_types: ["int", "text"],
argument_names: ["a", "b"],
argument_modes: ["IN", "OUT"],
language: "plpgsql",
source: "BEGIN PERFORM a; END",
kind: "p",
},
"edit",
);
expect(p).toMatchObject({
is_procedure: true,
action: {
op: "create_or_replace",
args: [
{ mode: "IN", name: "a", type: "int" },
{ mode: "OUT", name: "b", type: "text" },
],
return_type: "void",
body: "BEGIN PERFORM a; END",
},
});
});
it("function create keeps empty args/body and the default language", () => {
const p = initialCrudParams(
"function",
{ schema: "public", name: "add_one" },
"create",
);
expect(p.action).toMatchObject({
op: "create_or_replace",
args: [],
return_type: null,
language: "plpgsql",
body: "",
});
});
it("sequence edit prefills increment/min/max/start/cycle from the item", () => {
const p = initialCrudParams(
"sequence",
{
schema: "public",
name: "s",
start_value: "5",
min_value: "1",
max_value: "999",
increment: "2",
cycle: true,
},
"edit",
);
expect(p.action).toMatchObject({
op: "create",
increment: "2",
min_value: "1",
max_value: "999",
start: "5",
cycle: true,
});
});
it("trigger edit prefills table, timing, events, and orientation", () => {
const p = initialCrudParams(
"trigger",
{
schema: "public",
name: "trg",
table_name: "users",
event_manipulation: "INSERT",
action_timing: "AFTER",
action_orientation: "row",
},
"edit",
);
expect(p.action).toMatchObject({
op: "create",
table: "users",
timing: "AFTER",
events: ["INSERT"],
orientation: "ROW",
});
});
it("trigger edit splits OR-joined event_manipulation into separate events", () => {
const p = initialCrudParams(
"trigger",
{
schema: "public",
name: "trg",
table_name: "orders",
event_manipulation: "INSERT OR UPDATE",
action_timing: "BEFORE",
action_orientation: "STATEMENT",
},
"edit",
);
expect(p.action).toMatchObject({
events: ["INSERT", "UPDATE"],
orientation: "STATEMENT",
});
});
it("index edit prefills name, unique, method, and columns", () => {
const p = initialCrudParams(
"index",
{
schema: "public",
table: "users",
name: "idx_users_email",
is_unique: true,
method: "btree",
columns: ["email"],
},
"edit",
);
expect(p).toMatchObject({
schema: "public",
table: "users",
name: "idx_users_email",
action: {
op: "create",
unique: true,
method: "btree",
columns: ["email"],
predicate: null,
},
});
});
it("constraint edit maps CHECK contype to a check action with the definition", () => {
const p = initialCrudParams(
"constraint",
{
schema: "public",
table: "users",
name: "chk_age",
contype: "CHECK",
definition: "CHECK (age > 0)",
columns: ["age"],
},
"edit",
);
expect(p).toMatchObject({
schema: "public",
table: "users",
name: "chk_age",
action: { op: "check", expression: "CHECK (age > 0)" },
});
});
it("constraint edit maps UNIQUE contype to a unique action with columns", () => {
const p = initialCrudParams(
"constraint",
{
schema: "public",
table: "users",
name: "uniq_email",
contype: "UNIQUE",
definition: "UNIQUE (email)",
columns: ["email"],
},
"edit",
);
expect(p).toMatchObject({
name: "uniq_email",
action: { op: "unique", columns: ["email"] },
});
});
it("extension edit prefills the version from the item", () => {
const p = initialCrudParams(
"extension",
{ schema: "public", name: "pgcrypto", version: "1.3" },
"edit",
);
expect(p).toMatchObject({
name: "pgcrypto",
action: { op: "create", version: "1.3" },
});
});
it("view edit marks materialized when the item is a materialized view", () => {
const p = initialCrudParams(
"view",
{
schema: "public",
name: "mv",
table_type: "MATERIALIZED VIEW",
definition: "SELECT 1",
},
"edit",
);
expect(p).toMatchObject({
name: "mv",
materialized: true,
action: { op: "create", definition: "SELECT 1" },
});
});
});
describe("dropCrudParams", () => {
it("plain kinds build a base drop action", () => {
expect(
dropCrudParams("sequence", { schema: "public", name: "s" }),
).toEqual({ schema: "public", name: "s", action: { op: "drop" } });
});
it("index and constraint carry the owning table", () => {
expect(
dropCrudParams("index", { schema: "public", name: "i", table: "users" }),
).toEqual({
schema: "public",
name: "i",
table: "users",
action: { op: "drop" },
});
});
it("trigger uses table_name as a fallback for the table", () => {
expect(
dropCrudParams("trigger", {
schema: "public",
name: "trg",
table_name: "users",
}),
).toEqual({
schema: "public",
name: "trg",
action: { op: "drop", table: "users" },
});
});
it("view marks materialized via table_type", () => {
expect(
dropCrudParams("view", { schema: "public", name: "m", table_type: "m" }),
).toMatchObject({ materialized: true, action: { op: "drop" } });
});
it("function carries argument types and is_procedure false", () => {
expect(
dropCrudParams("function", {
schema: "public",
name: "f",
argument_types: ["int"],
}),
).toMatchObject({
is_procedure: false,
action: { op: "drop", arg_types: ["int"] },
});
});
});
+261
View File
@@ -0,0 +1,261 @@
import { invoke } from "@tauri-apps/api/core";
// NOTE on argument key naming: Tauri v2 converts Rust snake_case command
// parameter names to camelCase on the IPC boundary (connection_id ->
// connectionId). Single-word params (kind, params) are unchanged.
/// The object kinds supported by the Rust build_ddl dispatcher.
export type ObjectKind =
| "sequence"
| "enum"
| "view"
| "extension"
| "index"
| "constraint"
| "function"
| "procedure"
| "trigger";
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
/// The concrete shape is validated server-side by each kind's params struct.
export type DdlParams = Record<string, unknown>;
/// A browsable object row — the per-kind detail fields the CRUD helpers read.
/// All fields beyond `schema`/`name` are optional per object kind.
/// (The extra fields mirror the real per-kind shapes in `src/lib/types.ts`
/// and are consumed by `initialCrudParams` on edit.)
export interface CrudItem {
schema: string;
name: string;
table?: string;
table_name?: string;
table_type?: string;
materialized?: boolean;
labels?: string[];
definition?: string;
argument_types?: string[];
// function / procedure (FunctionInfo)
return_type?: string | null;
argument_names?: string[];
argument_modes?: string[];
language?: string;
source?: string | null;
kind?: string;
// sequence (SequenceInfo)
start_value?: string;
min_value?: string;
max_value?: string;
increment?: string;
cycle?: boolean;
// trigger (TriggerInfo)
event_manipulation?: string;
action_timing?: string;
action_orientation?: string;
enabled?: string;
// extension (ExtensionInfo)
version?: string | null;
// index (IndexInfo)
is_unique?: boolean;
method?: string;
columns?: string[];
// constraint (ConstraintInfo)
contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
}
/**
* Initial dialog params for a create/edit operation.
* Create starts empty (schema prefilled); edit prefills from the item.
*/
export function initialCrudParams(
kind: ObjectKind,
item: CrudItem,
mode: "create" | "edit",
): DdlParams {
const schema = item.schema;
switch (kind) {
case "sequence":
return {
schema,
name: mode === "edit" ? item.name : "",
action: {
op: "create",
increment: mode === "edit" ? (item.increment ?? "1") : "1",
min_value: mode === "edit" ? (item.min_value ?? "1") : "1",
max_value:
mode === "edit"
? (item.max_value ?? "9223372036854775807")
: "9223372036854775807",
start: mode === "edit" ? (item.start_value ?? "1") : "1",
cycle: mode === "edit" ? (item.cycle ?? false) : false,
},
};
case "enum":
return {
schema,
name: mode === "edit" ? item.name : "",
action: {
op: "create",
labels: mode === "edit" ? (item.labels ?? []) : [],
},
};
case "extension":
return {
schema,
name: mode === "edit" ? item.name : "",
action: {
op: "create",
version: mode === "edit" ? (item.version ?? null) : null,
},
};
case "view":
return {
schema,
name: mode === "edit" ? item.name : "",
materialized:
mode === "edit"
? item.table_type === "MATERIALIZED VIEW" || item.materialized === true
: false,
action: {
op: "create",
definition: mode === "edit" ? (item.definition ?? "") : "",
},
};
case "index":
return {
schema,
table: item.table ?? "",
name: mode === "edit" ? item.name : "",
action: {
op: "create",
unique: mode === "edit" ? (item.is_unique ?? false) : false,
method: mode === "edit" ? (item.method ?? "") : "",
columns: mode === "edit" ? (item.columns ?? []) : [],
predicate: null,
},
};
case "constraint":
return {
schema,
table: item.table ?? "",
name: mode === "edit" ? item.name : "",
action:
mode === "edit"
? item.contype === "UNIQUE"
? { op: "unique", columns: item.columns ?? [] }
: { op: "check", expression: item.definition ?? "" }
: { op: "check", expression: "" },
};
case "function":
case "procedure":
return {
schema,
name: mode === "edit" ? item.name : "",
is_procedure: kind === "procedure",
action: {
op: "create_or_replace",
args:
mode === "edit"
? (item.argument_names ?? []).map((n, i) => ({
mode: item.argument_modes?.[i] ?? "in",
name: n,
type: item.argument_types?.[i] ?? "",
}))
: [],
return_type: mode === "edit" ? (item.return_type ?? null) : null,
language: mode === "edit" ? (item.language ?? "plpgsql") : "plpgsql",
body: mode === "edit" ? (item.source ?? "") : "",
volatility: null,
strict: false,
},
};
case "trigger":
return {
schema,
name: mode === "edit" ? item.name : "",
action: {
op: "create",
table: item.table_name ?? "",
timing:
mode === "edit" ? (item.action_timing ?? "BEFORE") : "BEFORE",
events:
mode === "edit"
? (item.event_manipulation ?? "INSERT")
.split(/[,|]|\s+OR\s+/i)
.map((s) => s.trim())
.filter(Boolean)
: ["INSERT"],
orientation:
mode === "edit"
? (item.action_orientation ?? "ROW").toUpperCase()
: "ROW",
function_schema: schema,
function_name: "",
function_args: [],
when: null,
},
};
}
}
/**
* Minimal params that drop the object, carrying any per-kind context
* (owning table, argument types, materialized flag, …).
*/
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
const base = { schema: item.schema, name: item.name };
if (kind === "trigger") {
return {
...base,
action: { op: "drop", table: item.table ?? item.table_name ?? "" },
};
}
if (kind === "index" || kind === "constraint") {
return {
...base,
schema: item.schema,
table: item.table ?? "",
name: item.name,
action: { op: "drop" },
};
}
if (kind === "view") {
return {
...base,
materialized: item.table_type === "m" || item.materialized === true,
action: { op: "drop" },
};
}
if (kind === "function" || kind === "procedure") {
return {
...base,
is_procedure: kind === "procedure",
action: { op: "drop", arg_types: item.argument_types ?? [] },
};
}
return { ...base, action: { op: "drop" } };
}
/// An installable extension from pg_available_extensions.
export interface AvailableExtension {
name: string;
version: string;
comment: string | null;
}
/** Build one or more SQL statements for an object CRUD operation. */
export async function buildObjectDdl(
connectionId: string,
kind: ObjectKind,
params: DdlParams,
): Promise<string[]> {
return invoke<string[]>("build_object_ddl", { connectionId, kind, params });
}
/** List extensions available for install on the current server. */
export async function getAvailableExtensions(
connectionId: string,
): Promise<AvailableExtension[]> {
return invoke<AvailableExtension[]>("get_available_extensions", {
connectionId,
});
}
+4 -1
View File
@@ -46,6 +46,8 @@ export interface Connection {
environment?: string | null;
// Favorite flag (v0.5.0 — pinned connection)
favorite: boolean;
// Whether to save the password to the OS keychain (opt-out, default ON)
use_keychain?: boolean;
}
export type NewConnectionMode = "simple" | "detailed";
@@ -199,7 +201,8 @@ export type ChangeItemType =
| "create_index"
| "drop_index"
| "bulk_insert"
| "empty_table";
| "empty_table"
| "ddl";
export interface ChangeItem {
type: ChangeItemType;
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.7.5 across the app shell", () => {
expect(pkg.version).toBe("0.7.5");
it("declares v0.7.6 across the app shell", () => {
expect(pkg.version).toBe("0.7.6");
});
});