From eae3cece79f8e7943109a26ec7ce4ff26d834d25 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:21:25 +0200 Subject: [PATCH 01/48] feat: add notes, indexes, enums and type lengths to the schema model --- src/types/schema.ts | 35 +++++++++++++ src/utils/fileOperations.test.ts | 90 ++++++++++++++++++++++++++++++++ src/utils/zodSchemas.ts | 24 +++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/utils/fileOperations.test.ts diff --git a/src/types/schema.ts b/src/types/schema.ts index 117dcb1..2dbcab0 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -87,6 +87,17 @@ export const COLUMN_TYPES_BY_DB: Record = { postgresql: POSTGRESQL_COLUMN_TYPES, }; +export const LENGTH_TYPES: ReadonlySet = new Set([ + "varchar", + "char", +]); +export const PRECISION_TYPES: ReadonlySet = new Set([ + "decimal", + "numeric", +]); + +export type DefaultKind = "value" | "expression"; + export type RelationType = "1:1" | "1:N" | "N:1"; export type ReferentialAction = "CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION"; @@ -101,14 +112,37 @@ export interface Column { isUnique: boolean; isAutoIncrement: boolean; defaultValue: string | null; + defaultKind?: DefaultKind; + length?: number; + precision?: number; + scale?: number; + enumId?: string; + note?: string; } +export interface TableIndex { + id: string; + columnIds: string[]; + unique: boolean; + name?: string; +} + +// The new fields are optional so a file that uses none of them is saved +// unchanged: users commit .soca files and read the diffs. export interface Table { id: string; name: string; color?: string; position: { x: number; y: number }; columns: Column[]; + note?: string; + indexes?: TableIndex[]; +} + +export interface SchemaEnum { + id: string; + name: string; + values: string[]; } export interface Relation { @@ -128,4 +162,5 @@ export interface Schema { updatedAt: string; tables: Table[]; relations: Relation[]; + enums?: SchemaEnum[]; } diff --git a/src/utils/fileOperations.test.ts b/src/utils/fileOperations.test.ts new file mode 100644 index 0000000..d484f8c --- /dev/null +++ b/src/utils/fileOperations.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { parseSchemaContent } from "./fileOperations"; + +function column( + id: string, + name: string, + type: string, + extra: Record = {}, +) { + return { + id, + name, + type, + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue: null, + ...extra, + }; +} + +function file( + extra: Record = {}, + tableExtra: Record = {}, +) { + return { + version: "1.0", + name: "library", + dbType: "postgresql", + createdAt: "2026-10-13T00:00:00.000Z", + updatedAt: "2026-10-13T00:00:00.000Z", + tables: [ + { + id: "t1", + name: "loans", + position: { x: 0, y: 0 }, + columns: [ + column("c1", "id", "serial", { isPrimaryKey: true }), + column("c2", "status", "enum", { enumId: "e1", note: "Where it stands." }), + ], + ...tableExtra, + }, + ], + relations: [], + ...extra, + }; +} + +describe("parseSchemaContent", () => { + it("keeps notes, indexes and enums", () => { + const schema = parseSchemaContent( + JSON.stringify( + file( + { enums: [{ id: "e1", name: "loan_status", values: ["active", "returned"] }] }, + { + note: "One row per loan.", + indexes: [{ id: "i1", columnIds: ["c2"], unique: false }], + }, + ), + ), + ); + expect(schema.enums).toEqual([ + { id: "e1", name: "loan_status", values: ["active", "returned"] }, + ]); + expect(schema.tables[0].note).toBe("One row per loan."); + expect(schema.tables[0].indexes).toEqual([ + { id: "i1", columnIds: ["c2"], unique: false }, + ]); + expect(schema.tables[0].columns[1].note).toBe("Where it stands."); + expect(schema.tables[0].columns[1].enumId).toBe("e1"); + }); + + it("keeps type parameters and the default kind, and drops invalid values instead of failing", () => { + const f = file({ enums: [{ id: "e1", name: "loan_status", values: [] }] }); + f.tables[0].columns.push( + column("c3", "title", "varchar", { length: 120 }), + column("c4", "price", "numeric", { precision: 8, scale: 2 }), + column("c5", "code", "char", { length: -1 }), + column("c6", "label", "text", { defaultValue: "now()", defaultKind: "value" }), + ); + const schema = parseSchemaContent(JSON.stringify(f)); + const [, , title, price, code, label] = schema.tables[0].columns; + expect(title.length).toBe(120); + expect(price).toMatchObject({ precision: 8, scale: 2 }); + expect(code.length).toBeUndefined(); + expect(label.defaultKind).toBe("value"); + }); +}); diff --git a/src/utils/zodSchemas.ts b/src/utils/zodSchemas.ts index 55f1cf7..3f6f469 100644 --- a/src/utils/zodSchemas.ts +++ b/src/utils/zodSchemas.ts @@ -54,16 +54,39 @@ export const ColumnZ = z.object({ isUnique: z.boolean(), isAutoIncrement: z.boolean(), defaultValue: z.string().nullable(), + // A bad value in a hand-edited file is dropped rather than making the whole + // schema unreadable. + defaultKind: z.enum(["value", "expression"]).optional().catch(undefined), + length: z.number().int().positive().optional().catch(undefined), + precision: z.number().int().positive().optional().catch(undefined), + scale: z.number().int().nonnegative().optional().catch(undefined), + enumId: z.string().optional(), + note: z.string().optional(), }); const PositionZ = z.object({ x: z.number(), y: z.number() }); +export const TableIndexZ = z.object({ + id: z.string(), + columnIds: z.array(z.string()), + unique: z.boolean(), + name: z.string().optional(), +}); + export const TableZ = z.object({ id: z.string(), name: z.string(), color: z.string().optional(), position: PositionZ, columns: z.array(ColumnZ), + note: z.string().optional(), + indexes: z.array(TableIndexZ).optional(), +}); + +export const SchemaEnumZ = z.object({ + id: z.string(), + name: z.string(), + values: z.array(z.string()), }); const RelationEndpointZ = z.object({ @@ -88,6 +111,7 @@ export const SchemaZ = z.object({ updatedAt: z.string(), tables: z.array(TableZ), relations: z.array(RelationZ), + enums: z.array(SchemaEnumZ).optional(), }); export const McpColumnInputZ = z.object({ From ae729c9a63796f6079f062f1851e5b1701e0d45d Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:21:45 +0200 Subject: [PATCH 02/48] feat: keep enum links, indexes and type parameters consistent --- src/utils/schemaIntegrity.test.ts | 235 ++++++++++++++++++++++++++++++ src/utils/schemaIntegrity.ts | 158 ++++++++++++++++++++ src/utils/schemaQueries.ts | 6 +- 3 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/utils/schemaIntegrity.test.ts create mode 100644 src/utils/schemaIntegrity.ts diff --git a/src/utils/schemaIntegrity.test.ts b/src/utils/schemaIntegrity.test.ts new file mode 100644 index 0000000..0f04a53 --- /dev/null +++ b/src/utils/schemaIntegrity.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; +import type { Column, Schema, SchemaEnum, Table } from "../types/schema"; +import { + dropEnumsLeftUnused, + linkEnumColumns, + normalizeSchema, + pruneColumnFields, + pruneIndexes, + renameEnumsAgainst, + uniqueEnumName, +} from "./schemaIntegrity"; + +function col( + id: string, + name: string, + type: Column["type"], + extra: Partial = {}, +): Column { + return { + id, + name, + type, + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue: null, + ...extra, + }; +} + +function table( + id: string, + name: string, + columns: Column[], + extra: Partial = {}, +): Table { + return { id, name, position: { x: 0, y: 0 }, columns, ...extra }; +} + +function schema(tables: Table[], enums?: SchemaEnum[]): Schema { + return { + version: "1.0", + name: "s", + dbType: "postgresql", + createdAt: "", + updatedAt: "", + tables, + relations: [], + ...(enums ? { enums } : {}), + }; +} + +const status: SchemaEnum = { + id: "e1", + name: "loan_status", + values: ["active", "returned"], +}; + +describe("uniqueEnumName", () => { + it("returns the base when free", () => { + expect(uniqueEnumName("loans_status", [status])).toBe("loans_status"); + }); + it("adds a counter when taken", () => { + expect(uniqueEnumName("loan_status", [status])).toBe("loan_status_2"); + }); + it("avoids table names, which PostgreSQL also uses for types", () => { + expect(uniqueEnumName("loans", [], ["loans"])).toBe("loans_2"); + }); +}); + +describe("renameEnumsAgainst", () => { + it("renames incoming enums whose name is taken, including by each other", () => { + const incoming = [ + { id: "x1", name: "loan_status", values: [] }, + { id: "x2", name: "loan_status", values: [] }, + ]; + expect(renameEnumsAgainst([status], incoming).map((e) => e.name)).toEqual([ + "loan_status_2", + "loan_status_3", + ]); + }); +}); + +describe("linkEnumColumns", () => { + it("creates an empty enum for an enum column with no valid link", () => { + const tables = [table("t1", "orders", [col("c1", "status", "enum")])]; + const result = linkEnumColumns(tables, []); + expect(result.enums).toHaveLength(1); + expect(result.enums[0]).toMatchObject({ name: "orders_status", values: [] }); + expect(result.tables[0].columns[0].enumId).toBe(result.enums[0].id); + }); + + it("drops enumId from columns that are not enums", () => { + const tables = [ + table("t1", "loans", [col("c1", "status", "varchar", { enumId: "e1" })]), + ]; + const result = linkEnumColumns(tables, [status]); + expect(result.tables[0].columns[0]).not.toHaveProperty("enumId"); + }); + + it("returns the same arrays when nothing changes", () => { + const tables = [ + table("t1", "loans", [col("c1", "status", "enum", { enumId: "e1" })]), + ]; + const enums = [status]; + const result = linkEnumColumns(tables, enums); + expect(result.tables).toBe(tables); + expect(result.enums).toBe(enums); + }); +}); + +describe("pruneIndexes", () => { + it("removes missing columns and drops indexes left empty", () => { + const tables = [ + table("t1", "loans", [col("c1", "id", "serial"), col("c2", "due_on", "date")], { + indexes: [ + { id: "i1", columnIds: ["c2", "gone"], unique: false }, + { id: "i2", columnIds: ["gone"], unique: true }, + ], + }), + ]; + const result = pruneIndexes(tables); + expect(result[0].indexes).toEqual([{ id: "i1", columnIds: ["c2"], unique: false }]); + }); + + it("returns the same array when every index is valid", () => { + const tables = [ + table("t1", "loans", [col("c1", "id", "serial")], { + indexes: [{ id: "i1", columnIds: ["c1"], unique: false }], + }), + ]; + expect(pruneIndexes(tables)).toBe(tables); + }); +}); + +describe("dropEnumsLeftUnused", () => { + it("drops an enum the change left without columns", () => { + const before = [ + table("t1", "loans", [col("c1", "status", "enum", { enumId: "e1" })]), + ]; + const after = [table("t1", "loans", [col("c1", "status", "varchar")])]; + expect(dropEnumsLeftUnused(before, after, [status])).toEqual([]); + }); + + it("keeps an enum that was already unused", () => { + const before = [table("t1", "loans", [col("c1", "id", "serial")])]; + const after = [table("t1", "loans", [col("c1", "id", "integer")])]; + expect(dropEnumsLeftUnused(before, after, [status])).toEqual([status]); + }); + + it("keeps an enum another column still uses", () => { + const before = [ + table("t1", "loans", [ + col("c1", "status", "enum", { enumId: "e1" }), + col("c2", "previous", "enum", { enumId: "e1" }), + ]), + ]; + const after = [ + table("t1", "loans", [ + col("c1", "status", "varchar"), + col("c2", "previous", "enum", { enumId: "e1" }), + ]), + ]; + expect(dropEnumsLeftUnused(before, after, [status])).toEqual([status]); + }); +}); + +describe("pruneColumnFields", () => { + it("drops type parameters the type does not take", () => { + const tables = [ + table("t1", "books", [ + col("c1", "title", "text", { length: 120 }), + col("c2", "price", "integer", { precision: 8, scale: 2 }), + col("c3", "isbn", "varchar", { length: 13 }), + col("c4", "ratio", "numeric", { scale: 2 }), + ]), + ]; + const [title, price, isbn, ratio] = pruneColumnFields(tables)[0].columns; + expect(title).not.toHaveProperty("length"); + expect(price).not.toHaveProperty("precision"); + expect(price).not.toHaveProperty("scale"); + expect(isbn.length).toBe(13); + expect(ratio).not.toHaveProperty("scale"); + }); + + it("drops the default kind when there is no default", () => { + const tables = [ + table("t1", "books", [col("c1", "title", "text", { defaultKind: "value" })]), + ]; + expect(pruneColumnFields(tables)[0].columns[0]).not.toHaveProperty("defaultKind"); + }); + + it("returns the same array when every column is clean", () => { + const tables = [table("t1", "books", [col("c1", "isbn", "varchar", { length: 13 })])]; + expect(pruneColumnFields(tables)).toBe(tables); + }); +}); + +describe("normalizeSchema", () => { + it("returns the same object when nothing needs fixing", () => { + const s = schema([table("t1", "loans", [col("c1", "id", "serial")])]); + expect(normalizeSchema(s)).toBe(s); + }); + + it("does not add an enums key to a schema that has none", () => { + const prev = schema([table("t1", "loans", [col("c1", "id", "serial")])]); + const next = schema([table("t1", "loans_v2", [col("c1", "id", "serial")])]); + expect(normalizeSchema(next, prev)).not.toHaveProperty("enums"); + }); + + it("links, prunes and drops in one pass", () => { + const prev = schema( + [table("t1", "loans", [col("c1", "status", "enum", { enumId: "e1" })])], + [status], + ); + const next = schema( + [ + table( + "t1", + "loans", + [col("c1", "status", "varchar"), col("c2", "kind", "enum")], + { + indexes: [{ id: "i1", columnIds: ["gone"], unique: false }], + }, + ), + ], + [status], + ); + const result = normalizeSchema(next, prev); + expect(result.enums?.map((e) => e.name)).toEqual(["loans_kind"]); + expect(result.tables[0].indexes).toEqual([]); + }); +}); diff --git a/src/utils/schemaIntegrity.ts b/src/utils/schemaIntegrity.ts new file mode 100644 index 0000000..1d49aa9 --- /dev/null +++ b/src/utils/schemaIntegrity.ts @@ -0,0 +1,158 @@ +import type { Column, Schema, SchemaEnum, Table } from "../types/schema"; +import { LENGTH_TYPES, PRECISION_TYPES } from "../types/schema"; +import { genId } from "./id"; + +// PostgreSQL gives every table a row type of the same name, so an enum cannot +// reuse a table's name: CREATE TYPE would fail. +export function uniqueEnumName( + base: string, + enums: SchemaEnum[], + tableNames: string[] = [], +): string { + const taken = new Set([...enums.map((e) => e.name), ...tableNames]); + if (!taken.has(base)) return base; + let i = 2; + while (taken.has(`${base}_${i}`)) i++; + return `${base}_${i}`; +} + +export function renameEnumsAgainst( + existing: SchemaEnum[], + incoming: SchemaEnum[], + tableNames: string[] = [], +): SchemaEnum[] { + const taken = [...existing]; + return incoming.map((e) => { + const renamed = { ...e, name: uniqueEnumName(e.name, taken, tableNames) }; + taken.push(renamed); + return renamed; + }); +} + +// Every "enum" column must point to an enum the editor and the exporter can +// read: legacy files, SQL imports and the agent can all produce one without. +export function linkEnumColumns( + tables: Table[], + enums: SchemaEnum[], +): { tables: Table[]; enums: SchemaEnum[] } { + const known = new Set(enums.map((e) => e.id)); + const added: SchemaEnum[] = []; + let changed = false; + const nextTables = tables.map((t) => { + let tableChanged = false; + const columns = t.columns.map((c) => { + if (c.type === "enum") { + if (c.enumId && known.has(c.enumId)) return c; + const created: SchemaEnum = { + id: genId(), + name: uniqueEnumName( + `${t.name}_${c.name}`, + [...enums, ...added], + tables.map((x) => x.name), + ), + values: [], + }; + added.push(created); + known.add(created.id); + tableChanged = true; + return { ...c, enumId: created.id }; + } + if (c.enumId === undefined) return c; + tableChanged = true; + const rest = { ...c }; + delete rest.enumId; + return rest; + }); + if (!tableChanged) return t; + changed = true; + return { ...t, columns }; + }); + return changed + ? { tables: nextTables, enums: [...enums, ...added] } + : { tables, enums }; +} + +export function pruneIndexes(tables: Table[]): Table[] { + let changed = false; + const next = tables.map((t) => { + const current = t.indexes; + if (!current?.length) return t; + const columnIds = new Set(t.columns.map((c) => c.id)); + const indexes = current + .map((i) => + i.columnIds.every((id) => columnIds.has(id)) + ? i + : { ...i, columnIds: i.columnIds.filter((id) => columnIds.has(id)) }, + ) + .filter((i) => i.columnIds.length > 0); + if (indexes.length === current.length && indexes.every((i, k) => i === current[k])) + return t; + changed = true; + return { ...t, indexes }; + }); + return changed ? next : tables; +} + +function withoutStaleFields(c: Column): Column { + const { length, precision, scale, defaultKind, ...base } = c; + const decimal = PRECISION_TYPES.has(c.type); + const next: Column = { ...base }; + if (length !== undefined && LENGTH_TYPES.has(c.type)) next.length = length; + if (precision !== undefined && decimal) next.precision = precision; + if (scale !== undefined && decimal && precision !== undefined) next.scale = scale; + if (defaultKind !== undefined && c.defaultValue !== null) + next.defaultKind = defaultKind; + return next; +} + +const EXTRA_FIELDS = ["length", "precision", "scale", "defaultKind"] as const; + +export function pruneColumnFields(tables: Table[]): Table[] { + let changed = false; + const next = tables.map((t) => { + let tableChanged = false; + const columns = t.columns.map((c) => { + const clean = withoutStaleFields(c); + if (EXTRA_FIELDS.every((key) => clean[key] === c[key])) return c; + tableChanged = true; + return clean; + }); + if (!tableChanged) return t; + changed = true; + return { ...t, columns }; + }); + return changed ? next : tables; +} + +function enumIdsInUse(tables: Table[]): Set { + return new Set( + tables.flatMap((t) => t.columns.flatMap((c) => (c.enumId ? [c.enumId] : []))), + ); +} + +// Only enums that this change left unused go: an enum that was already unused +// when the file loaded (an imported CREATE TYPE) is the user's to delete. +export function dropEnumsLeftUnused( + before: Table[], + after: Table[], + enums: SchemaEnum[], +): SchemaEnum[] { + if (before === after || enums.length === 0) return enums; + const usedBefore = enumIdsInUse(before); + const usedAfter = enumIdsInUse(after); + const kept = enums.filter((e) => !usedBefore.has(e.id) || usedAfter.has(e.id)); + return kept.length === enums.length ? enums : kept; +} + +const NO_ENUMS: SchemaEnum[] = []; + +export function normalizeSchema(schema: Schema, prev?: Schema): Schema { + const current = schema.enums ?? NO_ENUMS; + const linked = linkEnumColumns(schema.tables, current); + const tables = pruneColumnFields(pruneIndexes(linked.tables)); + const enums = prev + ? dropEnumsLeftUnused(prev.tables, tables, linked.enums) + : linked.enums; + if (tables === schema.tables && enums === current) return schema; + return enums === current ? { ...schema, tables } : { ...schema, tables, enums }; +} diff --git a/src/utils/schemaQueries.ts b/src/utils/schemaQueries.ts index 98cb4da..853af2a 100644 --- a/src/utils/schemaQueries.ts +++ b/src/utils/schemaQueries.ts @@ -1,4 +1,4 @@ -import type { Schema, Table, Column, Relation } from "../types/schema"; +import type { Schema, Table, Column, Relation, SchemaEnum } from "../types/schema"; export function findTableById(schema: Schema, id: string): Table | undefined { return schema.tables.find((t) => t.id === id); @@ -16,6 +16,10 @@ export function findColumnByName(table: Table, name: string): Column | undefined return table.columns.find((c) => c.name === name); } +export function findEnumById(schema: Schema, id: string): SchemaEnum | undefined { + return schema.enums?.find((e) => e.id === id); +} + export function getRelationsFromTable(schema: Schema, tableId: string): Relation[] { return schema.relations.filter((r) => r.from.tableId === tableId); } From f70bd6becca296f19a2a1901c287dec3c11fd237 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:22:01 +0200 Subject: [PATCH 03/48] feat: tell value defaults from expression defaults --- src/utils/columnDefaults.test.ts | 36 +++++++++++++++++++++++++++++++- src/utils/columnDefaults.ts | 13 +++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/utils/columnDefaults.test.ts b/src/utils/columnDefaults.test.ts index 6f51ddd..690b1fe 100644 --- a/src/utils/columnDefaults.test.ts +++ b/src/utils/columnDefaults.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { createDefaultIdColumn } from "./columnDefaults"; +import type { Column } from "../types/schema"; +import { createDefaultIdColumn, defaultKindOf } from "./columnDefaults"; describe("createDefaultIdColumn", () => { it("creates a MySQL id column with auto increment", () => { @@ -28,3 +29,36 @@ describe("createDefaultIdColumn", () => { expect(a.id).not.toBe(b.id); }); }); + +describe("defaultKindOf", () => { + const withDefault = ( + defaultValue: string | null, + extra: Partial = {}, + ): Column => ({ + ...createDefaultIdColumn("postgresql"), + type: "varchar", + isPrimaryKey: false, + defaultValue, + ...extra, + }); + + it.each([ + "now()", + "gen_random_uuid()", + "CURRENT_TIMESTAMP", + "current_date", + "NULL", + "nextval('loans_id_seq')", + "'quoted by hand'", + ])("reads %s as an expression", (value) => { + expect(defaultKindOf(withDefault(value))).toBe("expression"); + }); + + it.each(["pending", "0", "true", "now", "a (b)"])("reads %s as a value", (value) => { + expect(defaultKindOf(withDefault(value))).toBe("value"); + }); + + it("lets an explicit kind win", () => { + expect(defaultKindOf(withDefault("now()", { defaultKind: "value" }))).toBe("value"); + }); +}); diff --git a/src/utils/columnDefaults.ts b/src/utils/columnDefaults.ts index 58c3d35..1275fbe 100644 --- a/src/utils/columnDefaults.ts +++ b/src/utils/columnDefaults.ts @@ -1,4 +1,4 @@ -import type { Column, DbType } from "../types/schema"; +import type { Column, DbType, DefaultKind } from "../types/schema"; import { genId } from "./id"; export function createDefaultIdColumn(dbType: DbType): Column { @@ -26,3 +26,14 @@ export function createDefaultIdColumn(dbType: DbType): Column { defaultValue: "gen_random_uuid()", }; } + +const EXPRESSION_DEFAULT = + /^(?:current_timestamp|current_date|current_time|localtimestamp|localtime|null|[a-z_][\w.]*\(.*\))$/is; + +// Files saved before defaultKind existed hold either a bare value or an +// expression. A value someone already wrapped in quotes is written as is. +export function defaultKindOf(column: Column): DefaultKind { + if (column.defaultKind) return column.defaultKind; + const value = column.defaultValue?.trim() ?? ""; + return /^'.*'$/s.test(value) || EXPRESSION_DEFAULT.test(value) ? "expression" : "value"; +} From 5c622cf2c2bb8bece97d10d88376fa8eeafa49e3 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:22:22 +0200 Subject: [PATCH 04/48] feat: add enum and index actions to the schema store --- src/stores/schemaStore.test.ts | 151 ++++++++++++++++++++++++++++ src/stores/schemaStore.ts | 177 ++++++++++++++++++++++++++++++--- 2 files changed, 316 insertions(+), 12 deletions(-) diff --git a/src/stores/schemaStore.test.ts b/src/stores/schemaStore.test.ts index c059f62..585523a 100644 --- a/src/stores/schemaStore.test.ts +++ b/src/stores/schemaStore.test.ts @@ -232,4 +232,155 @@ describe("schemaStore", () => { expect(useSchemaStore.getState().schema.tables).toHaveLength(2); }); }); + describe("enums", () => { + beforeEach(addTestTable); + + function addRole(defaultValue: string | null = "reader") { + useSchemaStore.getState().addColumn("t1", { + id: "c3", + name: "role", + type: "varchar", + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue, + }); + } + const column = (id: string) => + useSchemaStore.getState().schema.tables[0].columns.find((c) => c.id === id); + + it("turns a column into a new enum, keeping a default that is in the list", () => { + addRole(); + const id = useSchemaStore + .getState() + .createEnumForColumn("t1", "c3", ["reader", "librarian"]); + const { enums } = useSchemaStore.getState().schema; + expect(enums).toEqual([ + { id, name: "users_role", values: ["reader", "librarian"] }, + ]); + expect(column("c3")).toMatchObject({ + type: "enum", + enumId: id, + defaultValue: "reader", + }); + }); + + it("clears a default that is not in the new list", () => { + addRole("guest"); + useSchemaStore.getState().createEnumForColumn("t1", "c3", ["reader"]); + expect(column("c3")?.defaultValue).toBeNull(); + }); + + it("removes the enum when its only column goes back to text", () => { + addRole(); + useSchemaStore.getState().createEnumForColumn("t1", "c3", ["reader"]); + useSchemaStore.getState().updateColumn("t1", "c3", { type: "varchar" }); + expect(useSchemaStore.getState().schema.enums).toEqual([]); + expect(column("c3")).not.toHaveProperty("enumId"); + }); + + it("keeps a shared enum while another column uses it", () => { + addRole(); + const id = useSchemaStore.getState().createEnumForColumn("t1", "c3", ["reader"]); + useSchemaStore.getState().updateColumn("t1", "c2", { type: "enum", enumId: id }); + useSchemaStore.getState().updateColumn("t1", "c3", { type: "varchar" }); + expect(useSchemaStore.getState().schema.enums?.map((e) => e.id)).toEqual([id]); + }); + + it("gives an empty enum to a column set to enum without one", () => { + addRole(); + useSchemaStore.getState().updateColumn("t1", "c3", { type: "enum" }); + const enums = useSchemaStore.getState().schema.enums ?? []; + expect(enums).toHaveLength(1); + expect(column("c3")?.enumId).toBe(enums[0].id); + }); + + it("clears defaults that use a value removed from the enum", () => { + addRole(); + const id = useSchemaStore + .getState() + .createEnumForColumn("t1", "c3", ["reader", "librarian"]); + useSchemaStore.getState().updateEnum(id, { values: ["librarian"] }); + expect(column("c3")?.defaultValue).toBeNull(); + }); + + it("renames an enum", () => { + addRole(); + const id = useSchemaStore.getState().createEnumForColumn("t1", "c3", ["reader"]); + useSchemaStore.getState().updateEnum(id, { name: "member_role" }); + expect(useSchemaStore.getState().schema.enums?.[0].name).toBe("member_role"); + }); + + it("keeps an enum that was already unused when an unrelated change happens", () => { + const schema = useSchemaStore.getState().schema; + useSchemaStore.getState().setSchema({ + ...schema, + enums: [{ id: "e9", name: "orphan", values: ["a"] }], + }); + useSchemaStore.getState().updateTable("t1", { name: "people" }); + expect(useSchemaStore.getState().schema.enums?.map((e) => e.id)).toEqual(["e9"]); + }); + + it("does not add an enums key to a schema that never had one", () => { + useSchemaStore.getState().updateTable("t1", { name: "people" }); + expect(useSchemaStore.getState().schema).not.toHaveProperty("enums"); + }); + }); + + describe("indexes", () => { + beforeEach(addTestTable); + + const indexes = () => useSchemaStore.getState().schema.tables[0].indexes; + + it("adds, updates and deletes an index", () => { + const { addIndex, updateIndex, deleteIndex } = useSchemaStore.getState(); + addIndex("t1", { id: "i1", columnIds: ["c2"], unique: false }); + updateIndex("t1", "i1", { unique: true, name: "users_email_key" }); + expect(indexes()).toEqual([ + { id: "i1", columnIds: ["c2"], unique: true, name: "users_email_key" }, + ]); + deleteIndex("t1", "i1"); + expect(indexes()).toEqual([]); + }); + + it("removes a deleted column from its indexes and drops indexes left empty", () => { + const { addIndex, deleteColumn } = useSchemaStore.getState(); + addIndex("t1", { id: "i1", columnIds: ["c1", "c2"], unique: false }); + addIndex("t1", { id: "i2", columnIds: ["c2"], unique: true }); + deleteColumn("t1", "c2"); + expect(indexes()).toEqual([{ id: "i1", columnIds: ["c1"], unique: false }]); + }); + }); + + describe("type parameters", () => { + beforeEach(addTestTable); + + it("drops the length when the type no longer takes one", () => { + const { updateColumn } = useSchemaStore.getState(); + updateColumn("t1", "c2", { length: 120 }); + updateColumn("t1", "c2", { type: "text" }); + expect(useSchemaStore.getState().schema.tables[0].columns[1]).not.toHaveProperty( + "length", + ); + }); + }); + + describe("importTables with enums", () => { + it("adds incoming enums and renames those whose name is taken", () => { + const store = useSchemaStore.getState(); + store.setSchema({ + ...store.schema, + enums: [{ id: "e1", name: "status", values: ["a"] }], + }); + useSchemaStore + .getState() + .importTables([], [], [{ id: "e2", name: "status", values: ["b"] }]); + expect(useSchemaStore.getState().schema.enums?.map((e) => e.name)).toEqual([ + "status", + "status_2", + ]); + }); + }); }); diff --git a/src/stores/schemaStore.ts b/src/stores/schemaStore.ts index 6cff662..a774f73 100644 --- a/src/stores/schemaStore.ts +++ b/src/stores/schemaStore.ts @@ -1,6 +1,21 @@ import { create } from "zustand"; import { temporal } from "zundo"; -import type { Column, Table, Relation, Schema, DbType } from "../types/schema"; +import type { + Column, + Table, + Relation, + Schema, + DbType, + SchemaEnum, + TableIndex, +} from "../types/schema"; +import { genId } from "../utils/id"; +import { findColumnById, findTableById } from "../utils/schemaQueries"; +import { + normalizeSchema, + renameEnumsAgainst, + uniqueEnumName, +} from "../utils/schemaIntegrity"; interface SchemaState { schema: Schema; @@ -9,7 +24,6 @@ interface SchemaState { setSchema: (schema: Schema) => void; setFilePath: (path: string | null) => void; markSaved: () => void; - addTable: (table: Table) => void; updateTable: (id: string, updates: Partial
) => void; updateTablePositions: (positions: Record) => void; deleteTable: (id: string) => void; @@ -19,7 +33,17 @@ interface SchemaState { addRelation: (relation: Relation) => void; updateRelation: (id: string, updates: Partial) => void; deleteRelation: (id: string) => void; - importTables: (tables: Table[], relations: Relation[]) => void; + addTable: (table: Table, enums?: SchemaEnum[]) => void; + createEnumForColumn: (tableId: string, columnId: string, values: string[]) => string; + updateEnum: (id: string, updates: Partial>) => void; + addIndex: (tableId: string, index: TableIndex) => void; + updateIndex: ( + tableId: string, + indexId: string, + updates: Partial>, + ) => void; + deleteIndex: (tableId: string, indexId: string) => void; + importTables: (tables: Table[], relations: Relation[], enums?: SchemaEnum[]) => void; } export function createEmptySchema( @@ -38,7 +62,14 @@ export function createEmptySchema( } function patchSchema(schema: Schema, patch: Partial): Schema { - return { ...schema, ...patch, updatedAt: new Date().toISOString() }; + return normalizeSchema( + { ...schema, ...patch, updatedAt: new Date().toISOString() }, + schema, + ); +} + +function mapTable(tables: Table[], id: string, fn: (t: Table) => Table): Table[] { + return tables.map((t) => (t.id === id ? fn(t) : t)); } export const useSchemaStore = create()( @@ -52,10 +83,11 @@ export const useSchemaStore = create()( setFilePath: (path) => set({ filePath: path }), markSaved: () => set((state) => ({ savedAt: state.schema.updatedAt })), - addTable: (table) => + addTable: (table, enums = []) => set((state) => ({ schema: patchSchema(state.schema, { tables: [...state.schema.tables, table], + ...(enums.length ? { enums: [...(state.schema.enums ?? []), ...enums] } : {}), }), })), @@ -131,6 +163,112 @@ export const useSchemaStore = create()( }), })), + createEnumForColumn: (tableId, columnId, values) => { + const id = genId(); + set((state) => { + const table = findTableById(state.schema, tableId); + const column = table && findColumnById(table, columnId); + if (!table || !column) return state; + const enums = state.schema.enums ?? []; + const created: SchemaEnum = { + id, + name: uniqueEnumName( + `${table.name}_${column.name}`, + enums, + state.schema.tables.map((x) => x.name), + ), + values, + }; + const keepDefault = + column.defaultValue !== null && values.includes(column.defaultValue); + return { + schema: patchSchema(state.schema, { + enums: [...enums, created], + tables: mapTable(state.schema.tables, tableId, (t) => ({ + ...t, + columns: t.columns.map( + (c): Column => + c.id === columnId + ? { + ...c, + type: "enum", + enumId: id, + isAutoIncrement: false, + defaultValue: keepDefault ? c.defaultValue : null, + } + : c, + ), + })), + }), + }; + }); + return id; + }, + + updateEnum: (id, updates) => + set((state) => { + const values = updates.values; + const stale = (c: Column) => + values !== undefined && + c.enumId === id && + c.defaultValue !== null && + !values.includes(c.defaultValue); + const touched = state.schema.tables.some((t) => t.columns.some(stale)); + return { + schema: patchSchema(state.schema, { + enums: (state.schema.enums ?? []).map((e) => + e.id === id ? { ...e, ...updates } : e, + ), + ...(touched + ? { + tables: state.schema.tables.map((t) => + t.columns.some(stale) + ? { + ...t, + columns: t.columns.map((c) => + stale(c) ? { ...c, defaultValue: null } : c, + ), + } + : t, + ), + } + : {}), + }), + }; + }), + + addIndex: (tableId, index) => + set((state) => ({ + schema: patchSchema(state.schema, { + tables: mapTable(state.schema.tables, tableId, (t) => ({ + ...t, + indexes: [...(t.indexes ?? []), index], + })), + }), + })), + + updateIndex: (tableId, indexId, updates) => + set((state) => ({ + schema: patchSchema(state.schema, { + tables: mapTable(state.schema.tables, tableId, (t) => ({ + ...t, + indexes: (t.indexes ?? []).map((i) => + i.id === indexId ? { ...i, ...updates } : i, + ), + })), + }), + })), + + deleteIndex: (tableId, indexId) => + set((state) => ({ + schema: patchSchema(state.schema, { + tables: mapTable(state.schema.tables, tableId, (t) => ({ + ...t, + indexes: (t.indexes ?? []).filter((i) => i.id !== indexId), + })), + }), + })), + addRelation: (relation) => set((state) => ({ schema: patchSchema(state.schema, { @@ -190,13 +328,28 @@ export const useSchemaStore = create()( }; }), - importTables: (tables, relations) => - set((state) => ({ - schema: patchSchema(state.schema, { - tables: [...state.schema.tables, ...tables], - relations: [...state.schema.relations, ...relations], - }), - })), + importTables: (tables, relations, enums = []) => + set((state) => { + const current = state.schema.enums ?? []; + return { + schema: patchSchema(state.schema, { + tables: [...state.schema.tables, ...tables], + relations: [...state.schema.relations, ...relations], + ...(enums.length + ? { + enums: [ + ...current, + ...renameEnumsAgainst( + current, + enums, + [...state.schema.tables, ...tables].map((x) => x.name), + ), + ], + } + : {}), + }), + }; + }), }), { partialize: ({ schema }) => ({ schema }), From 239a1735eb645e24f6e5a0c8c751d73506b5fa92 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:22:41 +0200 Subject: [PATCH 05/48] fix: validate enum links when a schema is loaded --- src/utils/fileOperations.test.ts | 31 +++++++++++++++++++++++++++++++ src/utils/fileOperations.ts | 16 ++++++++++------ src/utils/sessionPersistence.ts | 8 ++++---- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/utils/fileOperations.test.ts b/src/utils/fileOperations.test.ts index d484f8c..d8603b1 100644 --- a/src/utils/fileOperations.test.ts +++ b/src/utils/fileOperations.test.ts @@ -87,4 +87,35 @@ describe("parseSchemaContent", () => { expect(code.length).toBeUndefined(); expect(label.defaultKind).toBe("value"); }); + it("gives legacy MySQL enum columns an enum to point to", () => { + const legacy = file({ dbType: "mysql" }); + legacy.tables[0].columns[1] = column("c2", "status", "enum"); + const schema = parseSchemaContent(JSON.stringify(legacy)); + expect(schema.enums).toHaveLength(1); + expect(schema.enums?.[0]).toMatchObject({ name: "loans_status", values: [] }); + expect(schema.tables[0].columns[1].enumId).toBe(schema.enums?.[0].id); + }); + + it("drops index columns that do not exist", () => { + const schema = parseSchemaContent( + JSON.stringify( + file( + { enums: [{ id: "e1", name: "loan_status", values: [] }] }, + { indexes: [{ id: "i1", columnIds: ["c1", "gone"], unique: false }] }, + ), + ), + ); + expect(schema.tables[0].indexes).toEqual([ + { id: "i1", columnIds: ["c1"], unique: false }, + ]); + }); + + it("adds no key to a file that uses none of the new fields", () => { + const plain = file(); + plain.tables[0].columns[1] = column("c2", "title", "varchar"); + const schema = parseSchemaContent(JSON.stringify(plain)); + expect(schema).not.toHaveProperty("enums"); + expect(schema.tables[0]).not.toHaveProperty("indexes"); + expect(schema.tables[0]).not.toHaveProperty("note"); + }); }); diff --git a/src/utils/fileOperations.ts b/src/utils/fileOperations.ts index 569f254..b06fef2 100644 --- a/src/utils/fileOperations.ts +++ b/src/utils/fileOperations.ts @@ -11,6 +11,7 @@ import { addRecentFile, removeRecentFile } from "./recentFiles"; import { IS_MAC } from "./platform"; import { pathForLog } from "./logPrivacy"; import { logError, logInfo, logWarn } from "./log"; +import { normalizeSchema } from "./schemaIntegrity"; export function migrateSchema(data: unknown) { if (!data || typeof data !== "object") return; @@ -36,6 +37,12 @@ export function getSocaFilter() { return { name: i18next.t("fileFilter.soca"), extensions: ["soca"] }; } +export function parseSchemaData(raw: unknown): Schema | null { + migrateSchema(raw); + const parsed = SchemaZ.safeParse(raw); + return parsed.success ? normalizeSchema(parsed.data) : null; +} + export function parseSchemaContent(content: string): Schema { let raw: unknown; try { @@ -43,12 +50,9 @@ export function parseSchemaContent(content: string): Schema { } catch { throw new Error(i18next.t("toast.invalidFile")); } - migrateSchema(raw); - const parsed = SchemaZ.safeParse(raw); - if (!parsed.success) { - throw new Error(i18next.t("toast.invalidFile")); - } - return parsed.data; + const schema = parseSchemaData(raw); + if (!schema) throw new Error(i18next.t("toast.invalidFile")); + return schema; } async function readSchemaFromPath(filePath: string): Promise { diff --git a/src/utils/sessionPersistence.ts b/src/utils/sessionPersistence.ts index af8837c..24990c3 100644 --- a/src/utils/sessionPersistence.ts +++ b/src/utils/sessionPersistence.ts @@ -1,7 +1,7 @@ import { useSchemaStore } from "../stores/schemaStore"; -import { migrateSchema } from "./fileOperations"; +import { parseSchemaData } from "./fileOperations"; import { isWorthResuming } from "./schemaQueries"; -import { LastSessionZ, SchemaZ } from "./zodSchemas"; +import { LastSessionZ } from "./zodSchemas"; import { logError, logWarn } from "./log"; const LAST_SESSION_KEY = "socadb_last_session"; @@ -39,8 +39,8 @@ export function restoreLastSession(): boolean { if (!raw) return false; try { const session = LastSessionZ.parse(JSON.parse(raw)); - migrateSchema(session.schema); - const schema = SchemaZ.parse(session.schema); + const schema = parseSchemaData(session.schema); + if (!schema) throw new Error("The saved session holds an invalid schema"); const filePath = session.filePath ?? null; const { setSchema, setFilePath } = useSchemaStore.getState(); setSchema(schema); From f9eb03b3689f0a1b4907323bbf8c8700776157c1 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:22:55 +0200 Subject: [PATCH 06/48] fix: keep notes, indexes and enums in JSON imports --- src/utils/importJson.test.ts | 45 +++++++++++++++++++++++++++ src/utils/importJson.ts | 60 ++++++++++++++++++++++++------------ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/utils/importJson.test.ts b/src/utils/importJson.test.ts index d0b9d51..2d813cc 100644 --- a/src/utils/importJson.test.ts +++ b/src/utils/importJson.test.ts @@ -133,4 +133,49 @@ describe("parseJsonSchema", () => { const result = parseJsonSchema(json); expect(result.detectedDbType).toBe("mysql"); }); + it("keeps notes and remaps indexes and enums to the new ids", () => { + const json = JSON.stringify({ + dbType: "postgresql", + enums: [{ id: "old-e1", name: "loan_status", values: ["active", "returned"] }], + tables: [ + { + id: "old-t1", + name: "loans", + note: "One row per loan.", + position: { x: 0, y: 0 }, + columns: [ + { + id: "old-c1", + name: "status", + type: "enum", + enumId: "old-e1", + note: "Where it stands.", + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue: "active", + }, + ], + indexes: [{ id: "old-i1", columnIds: ["old-c1"], unique: false }], + }, + ], + relations: [], + }); + const result = parseJsonSchema(json); + const table = result.tables[0]; + const column = table.columns[0]; + expect(table.note).toBe("One row per loan."); + expect(column.note).toBe("Where it stands."); + expect(result.enums).toHaveLength(1); + expect(result.enums[0].id).not.toBe("old-e1"); + expect(result.enums[0]).toMatchObject({ + name: "loan_status", + values: ["active", "returned"], + }); + expect(column.enumId).toBe(result.enums[0].id); + expect(table.indexes?.[0].id).not.toBe("old-i1"); + expect(table.indexes?.[0].columnIds).toEqual([column.id]); + }); }); diff --git a/src/utils/importJson.ts b/src/utils/importJson.ts index 7dc3e14..f23e26b 100644 --- a/src/utils/importJson.ts +++ b/src/utils/importJson.ts @@ -1,12 +1,14 @@ -import type { Table, Relation, DbType, Column } from "../types/schema"; +import type { Table, Relation, DbType, SchemaEnum } from "../types/schema"; import { migrateSchema } from "./fileOperations"; import { genId } from "./id"; -import { TableZ, RelationZ } from "./zodSchemas"; +import { linkEnumColumns, pruneColumnFields, pruneIndexes } from "./schemaIntegrity"; +import { TableZ, RelationZ, SchemaEnumZ } from "./zodSchemas"; import i18next from "../i18n"; export interface JsonImportResult { tables: Table[]; relations: Relation[]; + enums: SchemaEnum[]; detectedDbType: DbType; attempted: number; } @@ -35,34 +37,46 @@ export function parseJsonSchema(json: string): JsonImportResult { return result.success ? [result.data] : []; }); + const enumIds = new Map(); + const enums: SchemaEnum[] = ( + Array.isArray(data.enums) ? (data.enums as unknown[]) : [] + ).flatMap((e) => { + const r = SchemaEnumZ.safeParse(e); + if (!r.success) return []; + const id = genId(); + enumIds.set(r.data.id, id); + return [{ ...r.data, id }]; + }); + const idMap = new Map(); - const tables: Table[] = validTables.map((t) => { + const imported: Table[] = validTables.map((t) => { const newTableId = genId(); idMap.set(t.id, newTableId); - + const columns = t.columns.map((c) => { + const newColId = genId(); + idMap.set(c.id, newColId); + return { ...c, id: newColId, enumId: c.enumId ? enumIds.get(c.enumId) : undefined }; + }); + const indexes = t.indexes?.map((i) => ({ + ...i, + id: genId(), + columnIds: i.columnIds.flatMap((id) => { + const mapped = idMap.get(id); + return mapped ? [mapped] : []; + }), + })); return { ...t, id: newTableId, position: t.position ?? { x: 0, y: 0 }, - columns: t.columns.map((c: Column) => { - const newColId = genId(); - idMap.set(c.id, newColId); - return { - id: newColId, - name: c.name, - type: c.type, - isPrimaryKey: c.isPrimaryKey, - isForeignKey: c.isForeignKey, - isNullable: c.isNullable, - isUnique: c.isUnique, - isAutoIncrement: c.isAutoIncrement, - defaultValue: c.defaultValue, - }; - }), + columns, + ...(indexes ? { indexes } : {}), }; }); + const linked = linkEnumColumns(pruneColumnFields(pruneIndexes(imported)), enums); + const relations: Relation[] = validRelations .map((r) => { const fromTableId = idMap.get(r.from.tableId); @@ -81,5 +95,11 @@ export function parseJsonSchema(json: string): JsonImportResult { }) .filter((r): r is Relation => r !== null); - return { tables, relations, detectedDbType: dbType, attempted: data.tables.length }; + return { + tables: linked.tables, + relations, + enums: linked.enums, + detectedDbType: dbType, + attempted: data.tables.length, + }; } From 63dc7058fb413e83b883836e029194848f6bd1c1 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:23:19 +0200 Subject: [PATCH 07/48] feat: carry enums through SQL and JSON imports --- src/components/ImportModal/ImportModal.tsx | 22 ++++++++++---- src/utils/importSql.test.ts | 34 ++++++++++++++++++++++ src/utils/importSql.ts | 15 ++++++++-- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/components/ImportModal/ImportModal.tsx b/src/components/ImportModal/ImportModal.tsx index 74c7f96..22a6afd 100644 --- a/src/components/ImportModal/ImportModal.tsx +++ b/src/components/ImportModal/ImportModal.tsx @@ -14,7 +14,7 @@ import { parseJsonSchema } from "../../utils/importJson"; import { getNextTableColor } from "../../utils/tableColors"; import { handleAutoLayout } from "../../utils/schemaActions"; import { logError, logInfo, logWarn } from "../../utils/log"; -import type { DbType, Table, Relation } from "../../types/schema"; +import type { DbType, Table, Relation, SchemaEnum } from "../../types/schema"; type ImportFormat = "sql" | "json"; type ImportMode = "replace" | "merge"; @@ -22,6 +22,7 @@ type ImportMode = "replace" | "merge"; interface ConflictState { tables: Table[]; relations: Relation[]; + enums: SchemaEnum[]; fileDbType: DbType; currentDbType: DbType; skipped: number; @@ -75,6 +76,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { const replaceSchema = ( tables: Table[], relations: Relation[], + enums: SchemaEnum[], dbType: DbType, name: string, ) => { @@ -82,6 +84,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { const schema = createEmptySchema(name, dbType); schema.tables = assignColors(tables); schema.relations = relations; + if (enums.length) schema.enums = enums; store.setSchema(schema); store.setFilePath(null); useViewStore.getState().showEditor(); @@ -110,7 +113,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { if (!selected) return; const content = await readTextFile(selected); - const { tables, relations, detectedDbType, attempted } = + const { tables, relations, enums, detectedDbType, attempted } = format === "sql" ? parseSqlDdl(content) : parseJsonSchema(content); if (tables.length === 0) { @@ -126,6 +129,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { replaceSchema( tables, relations, + enums, detectedDbType ?? store.schema.dbType, fromHome ? fileStem(selected) || store.schema.name : store.schema.name, ); @@ -136,6 +140,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { setConflict({ tables, relations, + enums, fileDbType: detectedDbType, currentDbType, skipped, @@ -143,7 +148,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { return; } - mergeIntoSchema(tables, relations); + mergeIntoSchema(tables, relations, enums); } logInfo("import.done", "Import done", { @@ -163,7 +168,11 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { } }; - const mergeIntoSchema = (tables: Table[], relations: Relation[]) => { + const mergeIntoSchema = ( + tables: Table[], + relations: Relation[], + enums: SchemaEnum[], + ) => { const store = useSchemaStore.getState(); const existingNames = new Set(store.schema.tables.map((t) => t.name.toLowerCase())); const allTables = [...store.schema.tables]; @@ -185,7 +194,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { return result; }); - store.importTables(renamedTables, relations); + store.importTables(renamedTables, relations, enums); void handleAutoLayout(); }; @@ -194,6 +203,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { replaceSchema( conflict.tables, conflict.relations, + conflict.enums, conflict.fileDbType, useSchemaStore.getState().schema.name, ); @@ -211,7 +221,7 @@ export function ImportModal({ onClose, fromHome = false }: ImportModalProps) { conflict.tables, conflict.currentDbType, ); - mergeIntoSchema(adapted, conflict.relations); + mergeIntoSchema(adapted, conflict.relations, conflict.enums); showImportSummary( conflict.tables.length, conflict.relations.length, diff --git a/src/utils/importSql.test.ts b/src/utils/importSql.test.ts index 2e27312..8286197 100644 --- a/src/utils/importSql.test.ts +++ b/src/utils/importSql.test.ts @@ -60,6 +60,14 @@ describe("parseSqlDdl", () => { expect(result.tables).toHaveLength(0); expect(result.relations).toHaveLength(0); }); + it("links MySQL enum columns to an enum", () => { + const result = parseSqlDdl( + "CREATE TABLE orders (id INT AUTO_INCREMENT PRIMARY KEY, status ENUM('pending','paid') NOT NULL) ENGINE=InnoDB;", + ); + const status = result.tables[0].columns.find((c) => c.name === "status"); + expect(result.enums).toHaveLength(1); + expect(status?.enumId).toBe(result.enums[0].id); + }); }); describe("adaptColumnsToDbType", () => { @@ -114,4 +122,30 @@ describe("adaptColumnsToDbType", () => { expect(result.tables[0].columns[0].type).toBe("varchar"); expect(result.adaptedCount).toBe(0); }); + it("keeps enum columns, which both dialects support", () => { + const tables = [ + { + id: "t1", + name: "orders", + position: { x: 0, y: 0 }, + columns: [ + { + id: "c1", + name: "status", + type: "enum" as const, + enumId: "e1", + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue: null, + }, + ], + }, + ]; + const result = adaptColumnsToDbType(tables, "postgresql"); + expect(result.tables[0].columns[0]).toMatchObject({ type: "enum", enumId: "e1" }); + expect(result.adaptedCount).toBe(0); + }); }); diff --git a/src/utils/importSql.ts b/src/utils/importSql.ts index 1e261b6..f423a45 100644 --- a/src/utils/importSql.ts +++ b/src/utils/importSql.ts @@ -1,4 +1,5 @@ -import type { Table, Relation, DbType, ColumnType } from "../types/schema"; +import type { Table, Relation, DbType, ColumnType, SchemaEnum } from "../types/schema"; +import { linkEnumColumns } from "./schemaIntegrity"; import { COLUMN_TYPES_BY_DB } from "../types/schema"; import { stripComments, @@ -17,6 +18,7 @@ export interface SqlImportResult { relations: Relation[]; detectedDbType: DbType | null; attempted: number; + enums: SchemaEnum[]; } export function parseSqlDdl(sql: string): SqlImportResult { @@ -44,8 +46,15 @@ export function parseSqlDdl(sql: string): SqlImportResult { } const relations = resolveForeignKeys(tables, allPendingFks); + const linked = linkEnumColumns(tables, []); - return { tables, relations, detectedDbType, attempted }; + return { + tables: linked.tables, + relations, + enums: linked.enums, + detectedDbType, + attempted, + }; } export interface AdaptResult { @@ -60,7 +69,7 @@ export function adaptColumnsToDbType(tables: Table[], targetDbType: DbType): Ada const adapted = tables.map((table) => ({ ...table, columns: table.columns.map((col) => { - if (validTypes.has(col.type)) return col; + if (validTypes.has(col.type) || col.type === "enum") return col; adaptedCount++; return { ...col, type: "varchar" as ColumnType }; }), From 0b454dcfc6cbc0ecbf5ab7c50e3c3942ae789241 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:23:38 +0200 Subject: [PATCH 08/48] feat: copy notes, indexes and enums when duplicating a table --- src/utils/schemaActions.test.ts | 40 ++++++++++++++++++++++++++ src/utils/schemaActions.ts | 51 +++++++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/utils/schemaActions.test.ts b/src/utils/schemaActions.test.ts index b37db94..7424bee 100644 --- a/src/utils/schemaActions.test.ts +++ b/src/utils/schemaActions.test.ts @@ -100,6 +100,46 @@ describe("duplicateTable", () => { it("returns null for missing table", () => { expect(duplicateTable("missing")).toBeNull(); }); + it("copies the note and remaps index columns", () => { + const id = createTable({ name: "loans" }); + const store = useSchemaStore.getState(); + const colId = store.schema.tables[0].columns[0].id; + store.updateTable(id, { note: "One row per loan." }); + store.addIndex(id, { id: "i1", columnIds: [colId], unique: true }); + const copyId = duplicateTable(id); + const copy = useSchemaStore.getState().schema.tables.find((t) => t.id === copyId); + expect(copy?.note).toBe("One row per loan."); + expect(copy?.indexes).toHaveLength(1); + expect(copy?.indexes?.[0].id).not.toBe("i1"); + expect(copy?.indexes?.[0].columnIds).toEqual([copy?.columns[0].id]); + }); + + it("shares PostgreSQL enums with the copy", () => { + const id = createTable({ name: "loans" }); + const colId = useSchemaStore.getState().schema.tables[0].columns[0].id; + const enumId = useSchemaStore.getState().createEnumForColumn(id, colId, ["a"]); + const copyId = duplicateTable(id); + const { schema } = useSchemaStore.getState(); + const copy = schema.tables.find((t) => t.id === copyId); + expect(schema.enums).toHaveLength(1); + expect(copy?.columns[0].enumId).toBe(enumId); + }); + + it("gives the copy its own MySQL enums", () => { + useSchemaStore.setState({ schema: createEmptySchema("test", "mysql") }); + const id = createTable({ name: "orders" }); + const colId = useSchemaStore.getState().schema.tables[0].columns[0].id; + const enumId = useSchemaStore.getState().createEnumForColumn(id, colId, ["a", "b"]); + const copyId = duplicateTable(id); + const { schema } = useSchemaStore.getState(); + const copy = schema.tables.find((t) => t.id === copyId); + expect(schema.enums).toHaveLength(2); + expect(copy?.columns[0].enumId).not.toBe(enumId); + expect(schema.enums?.find((e) => e.id === copy?.columns[0].enumId)?.values).toEqual([ + "a", + "b", + ]); + }); }); describe("closeSchema", () => { diff --git a/src/utils/schemaActions.ts b/src/utils/schemaActions.ts index cfa669c..cc6abe2 100644 --- a/src/utils/schemaActions.ts +++ b/src/utils/schemaActions.ts @@ -4,11 +4,12 @@ import { genId } from "./id"; import { createDefaultIdColumn } from "./columnDefaults"; import { computeAutoLayout } from "./autoLayout"; import { getNextTableColor } from "./tableColors"; -import { findTableById, findTableByName } from "./schemaQueries"; +import { findEnumById, findTableById, findTableByName } from "./schemaQueries"; +import { uniqueEnumName } from "./schemaIntegrity"; import { logError } from "./log"; import { toast } from "sonner"; import i18next from "../i18n"; -import type { Column, Schema } from "../types/schema"; +import type { Column, Schema, SchemaEnum } from "../types/schema"; export function handleUndo() { useSchemaStore.temporal.getState().undo(); @@ -63,13 +64,47 @@ export function duplicateTable(tableId: string): string | null { const source = findTableById(schema, tableId); if (!source) return null; const newId = genId(); - addTable({ - id: newId, - name: uniqueTableName(`${source.name}_copy`, schema), - color: source.color, - position: { x: source.position.x + 40, y: source.position.y + 40 }, - columns: source.columns.map((col) => ({ ...col, id: genId(), isForeignKey: false })), + const name = uniqueTableName(`${source.name}_copy`, schema); + const columnIds = new Map(source.columns.map((c) => [c.id, genId()])); + // A MySQL enum belongs to its column (the SQL writes the values inline), so + // the copy gets its own instead of sharing the original's. + const clones: SchemaEnum[] = []; + const columns = source.columns.map((col) => { + const copy = { ...col, id: columnIds.get(col.id) ?? genId(), isForeignKey: false }; + const original = + schema.dbType === "mysql" && col.enumId + ? findEnumById(schema, col.enumId) + : undefined; + if (!original) return copy; + const clone: SchemaEnum = { + id: genId(), + name: uniqueEnumName( + `${name}_${col.name}`, + [...(schema.enums ?? []), ...clones], + [...schema.tables.map((t) => t.name), name], + ), + values: [...original.values], + }; + clones.push(clone); + return { ...copy, enumId: clone.id }; }); + addTable( + { + id: newId, + name, + color: source.color, + position: { x: source.position.x + 40, y: source.position.y + 40 }, + columns, + note: source.note, + indexes: source.indexes?.map((i) => ({ + ...i, + id: genId(), + name: undefined, + columnIds: i.columnIds.map((id) => columnIds.get(id) ?? id), + })), + }, + clones, + ); return newId; } From ee5e6a10b82f7b2bf85e6f3205cf3e0f10a4f0a3 Mon Sep 17 00:00:00 2001 From: cassidy Date: Tue, 13 Oct 2026 11:39:07 +0200 Subject: [PATCH 09/48] fix: save schemas opened from the home or restored at startup --- src-tauri/src/lib.rs | 1 + src-tauri/src/schema_files.rs | 29 +++++++++++++++++++++++++++ src/utils/fileOperations.save.test.ts | 22 ++++++++++++++++++++ src/utils/fileOperations.ts | 4 ++-- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/utils/fileOperations.save.test.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index befeb1d..aefc85a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -214,6 +214,7 @@ pub fn run() { schema_files::schema_files_info, schema_files::rename_schema_file, schema_files::duplicate_schema_file, + schema_files::write_schema_file, mcp_respond, atomic_write, open_terminal, diff --git a/src-tauri/src/schema_files.rs b/src-tauri/src/schema_files.rs index 999ed9a..80bc367 100644 --- a/src-tauri/src/schema_files.rs +++ b/src-tauri/src/schema_files.rs @@ -152,6 +152,21 @@ fn duplicate_file(path: &Path, label: &str) -> Result { Err("exists".into()) } +fn write_file(path: &Path, content: &str) -> Result<(), String> { + if !is_schema_path(path) { + return Err("Only .soca files can be saved".into()); + } + crate::write_atomic(path, content) +} + +// Saving goes through Rust because the fs plugin only writes paths picked in a +// dialog during this launch: a schema reopened from the home or restored at +// startup would be refused. +#[tauri::command(async)] +pub fn write_schema_file(path: String, content: String) -> Result<(), String> { + write_file(Path::new(&path), &content) +} + #[tauri::command(async)] pub fn duplicate_schema_file(path: String, copy_label: String) -> Result { duplicate_file(Path::new(&path), ©_label).map(|p| p.to_string_lossy().into_owned()) @@ -272,6 +287,20 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn save_writes_schema_files_only() { + let dir = temp_dir("save"); + let schema = dir.join("shop.soca"); + write_file(&schema, "{\"v\":1}").unwrap(); + write_file(&schema, "{\"v\":2}").unwrap(); + assert_eq!(std::fs::read_to_string(&schema).unwrap(), "{\"v\":2}"); + assert!(!dir.join("shop.tmp").exists()); + let other = dir.join("notes.txt"); + assert!(write_file(&other, "x").is_err()); + assert!(!other.exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn only_schema_files_are_touched() { let dir = temp_dir("ext"); diff --git a/src/utils/fileOperations.save.test.ts b/src/utils/fileOperations.save.test.ts new file mode 100644 index 0000000..ea27390 --- /dev/null +++ b/src/utils/fileOperations.save.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(() => Promise.resolve()) })); + +import { invoke } from "@tauri-apps/api/core"; +import { saveSchemaFile } from "./fileOperations"; +import { createEmptySchema } from "../stores/schemaStore"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("saveSchemaFile", () => { + it("writes through the Rust command, whatever path the schema came from", async () => { + const schema = createEmptySchema("shop", "mysql"); + await saveSchemaFile(schema, "/Users/ana/Documents/shop.soca"); + expect(invoke).toHaveBeenCalledWith("write_schema_file", { + path: "/Users/ana/Documents/shop.soca", + content: JSON.stringify(schema, null, 2), + }); + }); +}); diff --git a/src/utils/fileOperations.ts b/src/utils/fileOperations.ts index 569f254..ad7513a 100644 --- a/src/utils/fileOperations.ts +++ b/src/utils/fileOperations.ts @@ -1,5 +1,5 @@ import { open, save } from "@tauri-apps/plugin-dialog"; -import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; +import { readTextFile } from "@tauri-apps/plugin-fs"; import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import type { Schema } from "../types/schema"; @@ -74,7 +74,7 @@ export async function openSchemaFile(): Promise<{ export async function saveSchemaFile(schema: Schema, filePath: string): Promise { const content = JSON.stringify(schema, null, 2); - await writeTextFile(filePath, content); + await invoke("write_schema_file", { path: filePath, content }); } export async function saveSchemaFileAs(schema: Schema): Promise { From cb8d5aa1faf78df37bc81835e6037e1d692f8f86 Mon Sep 17 00:00:00 2001 From: cassidy Date: Wed, 14 Oct 2026 10:47:41 +0200 Subject: [PATCH 10/48] feat: add UNSIGNED to MySQL numeric columns --- src/types/schema.ts | 13 +++++++++++++ src/utils/importJson.ts | 5 ++++- src/utils/schemaIntegrity.test.ts | 15 +++++++++++++++ src/utils/schemaIntegrity.ts | 19 +++++++++++-------- src/utils/zodSchemas.ts | 1 + 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/types/schema.ts b/src/types/schema.ts index 2dbcab0..17aecce 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -80,6 +80,7 @@ export const POSTGRESQL_COLUMN_TYPES: ColumnType[] = [ "json", "jsonb", "bytea", + "enum", ]; export const COLUMN_TYPES_BY_DB: Record = { @@ -96,6 +97,17 @@ export const PRECISION_TYPES: ReadonlySet = new Set([ "numeric", ]); +export const MYSQL_NUMERIC_TYPES: ReadonlySet = new Set([ + "int", + "bigint", + "tinyint", + "smallint", + "mediumint", + "float", + "double", + "decimal", +]); + export type DefaultKind = "value" | "expression"; export type RelationType = "1:1" | "1:N" | "N:1"; @@ -116,6 +128,7 @@ export interface Column { length?: number; precision?: number; scale?: number; + unsigned?: boolean; enumId?: string; note?: string; } diff --git a/src/utils/importJson.ts b/src/utils/importJson.ts index f23e26b..db0dc69 100644 --- a/src/utils/importJson.ts +++ b/src/utils/importJson.ts @@ -75,7 +75,10 @@ export function parseJsonSchema(json: string): JsonImportResult { }; }); - const linked = linkEnumColumns(pruneColumnFields(pruneIndexes(imported)), enums); + const linked = linkEnumColumns( + pruneColumnFields(pruneIndexes(imported), dbType), + enums, + ); const relations: Relation[] = validRelations .map((r) => { diff --git a/src/utils/schemaIntegrity.test.ts b/src/utils/schemaIntegrity.test.ts index 0f04a53..6935a96 100644 --- a/src/utils/schemaIntegrity.test.ts +++ b/src/utils/schemaIntegrity.test.ts @@ -185,6 +185,21 @@ describe("pruneColumnFields", () => { expect(ratio).not.toHaveProperty("scale"); }); + it("keeps UNSIGNED only on MySQL numeric columns", () => { + const tables = [ + table("t1", "orders", [ + col("c1", "id", "int", { unsigned: true }), + col("c2", "code", "varchar", { unsigned: true }), + ]), + ]; + const [id, code] = pruneColumnFields(tables, "mysql")[0].columns; + expect(id.unsigned).toBe(true); + expect(code).not.toHaveProperty("unsigned"); + expect(pruneColumnFields(tables, "postgresql")[0].columns[0]).not.toHaveProperty( + "unsigned", + ); + }); + it("drops the default kind when there is no default", () => { const tables = [ table("t1", "books", [col("c1", "title", "text", { defaultKind: "value" })]), diff --git a/src/utils/schemaIntegrity.ts b/src/utils/schemaIntegrity.ts index 1d49aa9..e2cf767 100644 --- a/src/utils/schemaIntegrity.ts +++ b/src/utils/schemaIntegrity.ts @@ -1,5 +1,5 @@ -import type { Column, Schema, SchemaEnum, Table } from "../types/schema"; -import { LENGTH_TYPES, PRECISION_TYPES } from "../types/schema"; +import type { Column, DbType, Schema, SchemaEnum, Table } from "../types/schema"; +import { LENGTH_TYPES, MYSQL_NUMERIC_TYPES, PRECISION_TYPES } from "../types/schema"; import { genId } from "./id"; // PostgreSQL gives every table a row type of the same name, so an enum cannot @@ -93,8 +93,8 @@ export function pruneIndexes(tables: Table[]): Table[] { return changed ? next : tables; } -function withoutStaleFields(c: Column): Column { - const { length, precision, scale, defaultKind, ...base } = c; +function withoutStaleFields(c: Column, dbType: DbType | undefined): Column { + const { length, precision, scale, defaultKind, unsigned, ...base } = c; const decimal = PRECISION_TYPES.has(c.type); const next: Column = { ...base }; if (length !== undefined && LENGTH_TYPES.has(c.type)) next.length = length; @@ -102,17 +102,20 @@ function withoutStaleFields(c: Column): Column { if (scale !== undefined && decimal && precision !== undefined) next.scale = scale; if (defaultKind !== undefined && c.defaultValue !== null) next.defaultKind = defaultKind; + // UNSIGNED only exists in MySQL, on numeric columns. + if (unsigned && dbType !== "postgresql" && MYSQL_NUMERIC_TYPES.has(c.type)) + next.unsigned = true; return next; } -const EXTRA_FIELDS = ["length", "precision", "scale", "defaultKind"] as const; +const EXTRA_FIELDS = ["length", "precision", "scale", "defaultKind", "unsigned"] as const; -export function pruneColumnFields(tables: Table[]): Table[] { +export function pruneColumnFields(tables: Table[], dbType?: DbType): Table[] { let changed = false; const next = tables.map((t) => { let tableChanged = false; const columns = t.columns.map((c) => { - const clean = withoutStaleFields(c); + const clean = withoutStaleFields(c, dbType); if (EXTRA_FIELDS.every((key) => clean[key] === c[key])) return c; tableChanged = true; return clean; @@ -149,7 +152,7 @@ const NO_ENUMS: SchemaEnum[] = []; export function normalizeSchema(schema: Schema, prev?: Schema): Schema { const current = schema.enums ?? NO_ENUMS; const linked = linkEnumColumns(schema.tables, current); - const tables = pruneColumnFields(pruneIndexes(linked.tables)); + const tables = pruneColumnFields(pruneIndexes(linked.tables), schema.dbType); const enums = prev ? dropEnumsLeftUnused(prev.tables, tables, linked.enums) : linked.enums; diff --git a/src/utils/zodSchemas.ts b/src/utils/zodSchemas.ts index 3f6f469..6f5a14e 100644 --- a/src/utils/zodSchemas.ts +++ b/src/utils/zodSchemas.ts @@ -60,6 +60,7 @@ export const ColumnZ = z.object({ length: z.number().int().positive().optional().catch(undefined), precision: z.number().int().positive().optional().catch(undefined), scale: z.number().int().nonnegative().optional().catch(undefined), + unsigned: z.boolean().optional().catch(undefined), enumId: z.string().optional(), note: z.string().optional(), }); From b086a606096d6b544d6d4c0ac34f8947e1890cb4 Mon Sep 17 00:00:00 2001 From: cassidy Date: Wed, 14 Oct 2026 10:48:07 +0200 Subject: [PATCH 11/48] feat: add column type, enum and default helpers --- src/utils/columnActions.test.ts | 87 +++++++++++++++++ src/utils/columnActions.ts | 54 +++++++++++ src/utils/columnDefaults.test.ts | 28 +++++- src/utils/columnDefaults.ts | 23 ++++- src/utils/columnTypes.test.ts | 123 ++++++++++++++++++++++++ src/utils/columnTypes.ts | 159 +++++++++++++++++++++++++++++++ src/utils/enumHelpers.test.ts | 41 ++++++++ src/utils/enumHelpers.ts | 33 +++++++ 8 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 src/utils/columnActions.test.ts create mode 100644 src/utils/columnActions.ts create mode 100644 src/utils/columnTypes.test.ts create mode 100644 src/utils/columnTypes.ts create mode 100644 src/utils/enumHelpers.test.ts create mode 100644 src/utils/enumHelpers.ts diff --git a/src/utils/columnActions.test.ts b/src/utils/columnActions.test.ts new file mode 100644 index 0000000..0b0b29e --- /dev/null +++ b/src/utils/columnActions.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useSchemaStore, createEmptySchema } from "../stores/schemaStore"; +import { changeColumnType } from "./columnActions"; +import type { Column, DbType } from "../types/schema"; + +function col(id: string, name: string, extra: Partial = {}): Column { + return { + id, + name, + type: "varchar", + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue: null, + ...extra, + }; +} + +function setup(dbType: DbType, columns: Column[]) { + useSchemaStore.setState({ + schema: { + ...createEmptySchema("s", dbType), + tables: [{ id: "t1", name: "members", position: { x: 0, y: 0 }, columns }], + }, + filePath: null, + savedAt: null, + }); +} + +const column = (id: string) => + useSchemaStore.getState().schema.tables[0].columns.find((c) => c.id === id); + +describe("changeColumnType", () => { + beforeEach(() => setup("postgresql", [col("c1", "role", { defaultValue: "reader" })])); + + it("turns a column into a new enum whose first value is its text default", () => { + changeColumnType("t1", "c1", "enum"); + const { enums } = useSchemaStore.getState().schema; + expect(enums?.[0]).toMatchObject({ name: "members_role", values: ["reader"] }); + expect(column("c1")).toMatchObject({ type: "enum", defaultValue: "reader" }); + }); + + it("does not turn an expression default into an enum value", () => { + setup("postgresql", [col("c1", "code", { defaultValue: "gen_code()" })]); + changeColumnType("t1", "c1", "enum"); + expect(useSchemaStore.getState().schema.enums?.[0].values).toEqual([]); + }); + + it("reuses an enum and keeps the default only if it is one of its values", () => { + const store = useSchemaStore.getState(); + store.setSchema({ + ...store.schema, + enums: [{ id: "e1", name: "member_role", values: ["reader", "librarian"] }], + }); + changeColumnType("t1", "c1", "enum:e1"); + expect(column("c1")).toMatchObject({ + type: "enum", + enumId: "e1", + defaultValue: "reader", + }); + }); + + it("goes back to text and drops the enum it no longer uses", () => { + changeColumnType("t1", "c1", "enum"); + changeColumnType("t1", "c1", "varchar"); + expect(column("c1")?.type).toBe("varchar"); + expect(column("c1")).not.toHaveProperty("enumId"); + expect(useSchemaStore.getState().schema.enums).toEqual([]); + }); + + it("clears a default a boolean cannot take and auto-increment outside MySQL integers", () => { + setup("mysql", [ + col("c1", "id", { type: "int", isAutoIncrement: true, defaultValue: "x" }), + ]); + changeColumnType("t1", "c1", "bigint"); + expect(column("c1")?.isAutoIncrement).toBe(true); + changeColumnType("t1", "c1", "boolean"); + expect(column("c1")).toMatchObject({ isAutoIncrement: false, defaultValue: null }); + }); + + it("ignores values that are not column types", () => { + changeColumnType("t1", "c1", "not-a-type"); + expect(column("c1")?.type).toBe("varchar"); + }); +}); diff --git a/src/utils/columnActions.ts b/src/utils/columnActions.ts new file mode 100644 index 0000000..78a7e37 --- /dev/null +++ b/src/utils/columnActions.ts @@ -0,0 +1,54 @@ +import { useSchemaStore } from "../stores/schemaStore"; +import { defaultKindOf } from "./columnDefaults"; +import { MYSQL_INT_TYPES } from "./columnTypes"; +import { findColumnById, findEnumById, findTableById } from "./schemaQueries"; +import { ColumnTypeZ } from "./zodSchemas"; +import type { Column, ColumnType } from "../types/schema"; + +const isColumnType = (value: string): value is ColumnType => + ColumnTypeZ.safeParse(value).success; + +// `value` comes from the type menu: a column type, or `enum:` to reuse an +// existing PostgreSQL enum. +export function changeColumnType(tableId: string, columnId: string, value: string) { + const store = useSchemaStore.getState(); + const table = findTableById(store.schema, tableId); + const column = table && findColumnById(table, columnId); + if (!column) return; + + if (value === "enum") { + if (column.type === "enum") return; + const current = column.defaultValue?.trim(); + const first = current && defaultKindOf(column) === "value" ? [current] : []; + store.createEnumForColumn(tableId, columnId, first); + return; + } + + if (value.startsWith("enum:")) { + const target = findEnumById(store.schema, value.slice(5)); + if (!target) return; + const keep = + column.defaultValue !== null && target.values.includes(column.defaultValue); + store.updateColumn(tableId, columnId, { + type: "enum", + enumId: target.id, + isAutoIncrement: false, + defaultValue: keep ? column.defaultValue : null, + }); + return; + } + + if (!isColumnType(value)) return; + const updates: Partial = { type: value }; + if (store.schema.dbType === "postgresql" || !MYSQL_INT_TYPES.has(value)) { + updates.isAutoIncrement = false; + } + if ( + value === "boolean" && + column.defaultValue !== "true" && + column.defaultValue !== "false" + ) { + updates.defaultValue = null; + } + store.updateColumn(tableId, columnId, updates); +} diff --git a/src/utils/columnDefaults.test.ts b/src/utils/columnDefaults.test.ts index 690b1fe..165dd44 100644 --- a/src/utils/columnDefaults.test.ts +++ b/src/utils/columnDefaults.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { Column } from "../types/schema"; -import { createDefaultIdColumn, defaultKindOf } from "./columnDefaults"; +import { createDefaultIdColumn, defaultKindOf, defaultSql } from "./columnDefaults"; describe("createDefaultIdColumn", () => { it("creates a MySQL id column with auto increment", () => { @@ -62,3 +62,29 @@ describe("defaultKindOf", () => { expect(defaultKindOf(withDefault("now()", { defaultKind: "value" }))).toBe("value"); }); }); + +describe("defaultSql", () => { + const base = createDefaultIdColumn("postgresql"); + const sql = (extra: Partial) => + defaultSql({ ...base, isPrimaryKey: false, ...extra }); + + it("writes expressions as they are", () => { + expect(sql({ type: "timestamptz", defaultValue: "now()" })).toBe("now()"); + }); + + it("quotes text and doubles quotes inside it", () => { + expect(sql({ type: "varchar", defaultValue: "it's" })).toBe("'it''s'"); + expect(sql({ type: "varchar", defaultValue: "0" })).toBe("'0'"); + }); + + it("leaves numbers and booleans bare on their own types", () => { + expect(sql({ type: "numeric", defaultValue: "1.5" })).toBe("1.5"); + expect(sql({ type: "integer", defaultValue: "0" })).toBe("0"); + expect(sql({ type: "boolean", defaultValue: "true" })).toBe("true"); + }); + + it("returns null without a default", () => { + expect(sql({ defaultValue: null })).toBeNull(); + expect(sql({ defaultValue: " " })).toBeNull(); + }); +}); diff --git a/src/utils/columnDefaults.ts b/src/utils/columnDefaults.ts index 1275fbe..6f52544 100644 --- a/src/utils/columnDefaults.ts +++ b/src/utils/columnDefaults.ts @@ -1,4 +1,5 @@ -import type { Column, DbType, DefaultKind } from "../types/schema"; +import type { Column, ColumnType, DbType, DefaultKind } from "../types/schema"; +import { MYSQL_NUMERIC_TYPES } from "../types/schema"; import { genId } from "./id"; export function createDefaultIdColumn(dbType: DbType): Column { @@ -37,3 +38,23 @@ export function defaultKindOf(column: Column): DefaultKind { const value = column.defaultValue?.trim() ?? ""; return /^'.*'$/s.test(value) || EXPRESSION_DEFAULT.test(value) ? "expression" : "value"; } + +const NUMBER_TYPES: ReadonlySet = new Set([ + ...MYSQL_NUMERIC_TYPES, + "integer", + "numeric", + "real", + "double precision", + "serial", + "bigserial", +]); + +// How the SQL export writes a default; also shown as a hint in the editor. +export function defaultSql(column: Column): string | null { + const value = column.defaultValue?.trim(); + if (!value) return null; + if (defaultKindOf(column) === "expression") return value; + if (NUMBER_TYPES.has(column.type) && /^-?\d+(\.\d+)?$/.test(value)) return value; + if (column.type === "boolean" && /^(true|false)$/i.test(value)) return value; + return `'${value.replace(/'/g, "''")}'`; +} diff --git a/src/utils/columnTypes.test.ts b/src/utils/columnTypes.test.ts new file mode 100644 index 0000000..54eeddd --- /dev/null +++ b/src/utils/columnTypes.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import type { Column, SchemaEnum, Table } from "../types/schema"; +import { + isUniqueColumn, + timeSuggestions, + typeLabel, + typeMenuGroups, +} from "./columnTypes"; + +const col = (extra: Partial): Column => ({ + id: "c1", + name: "x", + type: "varchar", + isPrimaryKey: false, + isForeignKey: false, + isNullable: false, + isUnique: false, + isAutoIncrement: false, + defaultValue: null, + ...extra, +}); + +const status: SchemaEnum = { id: "e1", name: "loan_status", values: ["active", "lost"] }; +const genre: SchemaEnum = { id: "e2", name: "book_genre", values: ["fiction"] }; + +describe("typeMenuGroups", () => { + it("groups PostgreSQL types and lists other enums to reuse", () => { + const groups = typeMenuGroups("postgresql", "", [status, genre], "e1"); + expect(groups.map((g) => g.id)).toEqual([ + "numbers", + "text", + "dateTime", + "other", + "reuseEnum", + ]); + expect(groups.find((g) => g.id === "other")?.items.map((i) => i.value)).toContain( + "enum", + ); + expect(groups.find((g) => g.id === "reuseEnum")?.items).toEqual([ + { value: "enum:e2", label: "book_genre", enumValues: ["fiction"] }, + ]); + }); + + it("filters by the query and lists every enum when searching enum", () => { + expect( + typeMenuGroups("postgresql", "ti", [], undefined).flatMap((g) => + g.items.map((i) => i.label), + ), + ).toEqual(["time", "timestamp", "timestamptz"]); + const reuse = typeMenuGroups("postgresql", "enum", [status, genre], undefined).find( + (g) => g.id === "reuseEnum", + ); + expect(reuse?.items.map((i) => i.label)).toEqual(["loan_status", "book_genre"]); + }); + + it("never offers to reuse an enum in MySQL", () => { + const groups = typeMenuGroups("mysql", "", [status], undefined); + expect(groups.some((g) => g.id === "reuseEnum")).toBe(false); + expect(groups.find((g) => g.id === "other")?.items.map((i) => i.value)).toContain( + "enum", + ); + }); + + it("drops empty groups", () => { + expect(typeMenuGroups("mysql", "zz", [], undefined)).toEqual([]); + }); +}); + +describe("typeLabel", () => { + it("shows lengths and precision", () => { + expect(typeLabel(col({ type: "varchar", length: 120 }))).toBe("varchar(120)"); + expect(typeLabel(col({ type: "decimal", precision: 10, scale: 2 }))).toBe( + "decimal(10,2)", + ); + expect(typeLabel(col({ type: "numeric", precision: 8 }))).toBe("numeric(8)"); + expect(typeLabel(col({ type: "text" }))).toBe("text"); + }); +}); + +describe("timeSuggestions", () => { + it("fits the type and the dialect", () => { + expect(timeSuggestions("date", "postgresql").map((s) => s.value)).toEqual([ + "CURRENT_DATE", + ]); + expect(timeSuggestions("timestamptz", "postgresql").map((s) => s.value)).toEqual([ + "now()", + "CURRENT_TIMESTAMP", + ]); + expect(timeSuggestions("datetime", "mysql").map((s) => s.value)).toEqual([ + "CURRENT_TIMESTAMP", + ]); + }); +}); + +describe("isUniqueColumn", () => { + const table = (columns: Column[], indexes: Table["indexes"] = []): Table => ({ + id: "t1", + name: "t", + position: { x: 0, y: 0 }, + columns, + indexes, + }); + + it("counts the flag and one-column unique indexes, not the primary key", () => { + const pk = col({ id: "pk", isPrimaryKey: true, isUnique: true }); + const flagged = col({ id: "a", isUnique: true }); + const indexed = col({ id: "b" }); + const composite = col({ id: "c" }); + const t = table( + [pk, flagged, indexed, composite], + [ + { id: "i1", columnIds: ["b"], unique: true }, + { id: "i2", columnIds: ["c", "b"], unique: true }, + ], + ); + expect([pk, flagged, indexed, composite].map((c) => isUniqueColumn(t, c))).toEqual([ + false, + true, + true, + false, + ]); + }); +}); diff --git a/src/utils/columnTypes.ts b/src/utils/columnTypes.ts new file mode 100644 index 0000000..a150484 --- /dev/null +++ b/src/utils/columnTypes.ts @@ -0,0 +1,159 @@ +import type { Column, ColumnType, DbType, SchemaEnum, Table } from "../types/schema"; +import { LENGTH_TYPES, PRECISION_TYPES } from "../types/schema"; + +export type TypeGroupId = "numbers" | "text" | "dateTime" | "other" | "reuseEnum"; + +const TYPE_GROUPS: Record = { + postgresql: [ + { + id: "numbers", + types: [ + "integer", + "bigint", + "smallint", + "serial", + "bigserial", + "numeric", + "real", + "double precision", + ], + }, + { id: "text", types: ["varchar", "char", "text"] }, + { id: "dateTime", types: ["date", "time", "timestamp", "timestamptz"] }, + { id: "other", types: ["boolean", "uuid", "enum", "json", "jsonb", "bytea"] }, + ], + mysql: [ + { + id: "numbers", + types: [ + "int", + "bigint", + "tinyint", + "smallint", + "mediumint", + "decimal", + "float", + "double", + ], + }, + { id: "text", types: ["varchar", "char", "text", "mediumtext", "longtext"] }, + { id: "dateTime", types: ["date", "time", "datetime", "timestamp"] }, + { id: "other", types: ["boolean", "enum", "json", "blob"] }, + ], +}; + +// Types whose name alone leaves people guessing; the menu shows a short hint. +export const HINTED_TYPES: ReadonlySet = new Set([ + "serial", + "bigserial", + "numeric", + "decimal", + "real", + "float", + "double", + "double precision", + "text", + "timestamptz", + "jsonb", + "bytea", + "blob", + "enum", + "uuid", +]); + +export const MYSQL_INT_TYPES: ReadonlySet = new Set([ + "int", + "bigint", + "tinyint", + "smallint", + "mediumint", +]); + +export const TIME_TYPES: ReadonlySet = new Set([ + "date", + "time", + "datetime", + "timestamp", + "timestamptz", +]); + +export interface TypeMenuItem { + value: string; + label: string; + type?: ColumnType; + enumValues?: string[]; +} + +export interface TypeMenuGroup { + id: TypeGroupId; + items: TypeMenuItem[]; +} + +export const reuseEnumValue = (enumId: string) => `enum:${enumId}`; + +export function typeMenuGroups( + dbType: DbType, + query: string, + enums: SchemaEnum[], + currentEnumId: string | undefined, +): TypeMenuGroup[] { + const q = query.trim().toLowerCase(); + const hit = (text: string) => !q || text.toLowerCase().includes(q); + const groups: TypeMenuGroup[] = TYPE_GROUPS[dbType].map(({ id, types }) => ({ + id, + items: types.filter(hit).map((type) => ({ value: type, label: type, type })), + })); + // MySQL writes enum values inline, so only PostgreSQL can share one. + if (dbType === "postgresql") { + groups.push({ + id: "reuseEnum", + items: enums + .filter((e) => e.id !== currentEnumId && (hit(e.name) || hit("enum"))) + .map((e) => ({ + value: reuseEnumValue(e.id), + label: e.name, + enumValues: e.values, + })), + }); + } + return groups.filter((g) => g.items.length > 0); +} + +export function typeLabel(column: Column): string { + if (LENGTH_TYPES.has(column.type) && column.length) { + return `${column.type}(${column.length})`; + } + if (PRECISION_TYPES.has(column.type) && column.precision) { + const scale = column.scale !== undefined ? `,${column.scale}` : ""; + return `${column.type}(${column.precision}${scale})`; + } + return column.type; +} + +export interface TimeSuggestion { + value: string; + hintKey: "insertDay" | "insertTime" | "insertMoment" | "sameAsNow"; +} + +export function timeSuggestions(type: ColumnType, dbType: DbType): TimeSuggestion[] { + if (type === "date") return [{ value: "CURRENT_DATE", hintKey: "insertDay" }]; + if (type === "time") return [{ value: "CURRENT_TIME", hintKey: "insertTime" }]; + return dbType === "postgresql" + ? [ + { value: "now()", hintKey: "insertMoment" }, + { value: "CURRENT_TIMESTAMP", hintKey: "sameAsNow" }, + ] + : [{ value: "CURRENT_TIMESTAMP", hintKey: "insertMoment" }]; +} + +// The key icon already says a primary key is unique. A one-column unique index +// says the same as the UNIQUE flag. +export function isUniqueColumn(table: Table, column: Column): boolean { + if (column.isPrimaryKey) return false; + return ( + column.isUnique || + (table.indexes ?? []).some( + (i) => i.unique && i.columnIds.length === 1 && i.columnIds[0] === column.id, + ) + ); +} diff --git a/src/utils/enumHelpers.test.ts b/src/utils/enumHelpers.test.ts new file mode 100644 index 0000000..78d13d7 --- /dev/null +++ b/src/utils/enumHelpers.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { createEmptySchema } from "../stores/schemaStore"; +import { addValues, enumNameError, parseValueList } from "./enumHelpers"; + +describe("parseValueList", () => { + it("splits on commas, tabs and lines and drops blanks", () => { + expect(parseValueList(" art, comics\nsports\t\t, ")).toEqual([ + "art", + "comics", + "sports", + ]); + }); +}); + +describe("addValues", () => { + it("appends new values once, in order", () => { + expect(addValues(["a", "b"], "b, c\na")).toEqual(["a", "b", "c"]); + }); +}); + +describe("enumNameError", () => { + const schema = { + ...createEmptySchema("s", "postgresql"), + tables: [{ id: "t1", name: "loans", position: { x: 0, y: 0 }, columns: [] }], + enums: [ + { id: "e1", name: "loan_status", values: [] }, + { id: "e2", name: "book_genre", values: [] }, + ], + }; + + it("refuses empty names, table names and other enums' names", () => { + expect(enumNameError(schema, "e1", " ")).toBe("empty"); + expect(enumNameError(schema, "e1", "loans")).toBe("table"); + expect(enumNameError(schema, "e1", "book_genre")).toBe("enum"); + }); + + it("accepts the enum's own name and free names", () => { + expect(enumNameError(schema, "e1", "loan_status")).toBeNull(); + expect(enumNameError(schema, "e1", "loan_state")).toBeNull(); + }); +}); diff --git a/src/utils/enumHelpers.ts b/src/utils/enumHelpers.ts new file mode 100644 index 0000000..ca71504 --- /dev/null +++ b/src/utils/enumHelpers.ts @@ -0,0 +1,33 @@ +import type { Schema } from "../types/schema"; + +export function parseValueList(raw: string): string[] { + return raw + .split(/[,\n\r\t]+/) + .map((v) => v.trim()) + .filter(Boolean); +} + +export function addValues(existing: string[], raw: string): string[] { + const next = [...existing]; + for (const value of parseValueList(raw)) { + if (!next.includes(value)) next.push(value); + } + return next; +} + +export type EnumNameError = "empty" | "table" | "enum"; + +// PostgreSQL gives every table a type of the same name, so CREATE TYPE would +// fail on a table's name as well as on another enum's. +export function enumNameError( + schema: Schema, + enumId: string, + name: string, +): EnumNameError | null { + const trimmed = name.trim(); + if (!trimmed) return "empty"; + if (schema.tables.some((t) => t.name === trimmed)) return "table"; + if ((schema.enums ?? []).some((e) => e.id !== enumId && e.name === trimmed)) + return "enum"; + return null; +} From a7f9dcea5a15259b0f01c238d524e78087b2cff6 Mon Sep 17 00:00:00 2001 From: cassidy Date: Wed, 14 Oct 2026 10:48:33 +0200 Subject: [PATCH 12/48] feat: edit columns in a one-line list with a column editor --- .../ActionMenu.tsx} | 8 +- src/components/Home/Home.tsx | 4 +- src/components/Home/HomeCard.tsx | 6 +- src/components/NoteBubble/NoteBubble.tsx | 121 +++++ src/components/PickMenu/PickMenu.tsx | 276 ++++++++++ src/components/SidePanel/ColumnEditor.tsx | 502 ++++++++++++++++++ src/components/SidePanel/ColumnIcon.tsx | 20 + src/components/SidePanel/ColumnRow.tsx | 109 ++++ src/components/SidePanel/ConstraintBadge.tsx | 37 ++ src/components/SidePanel/DefaultField.tsx | 244 +++++++++ src/components/SidePanel/EnumValues.tsx | 78 +++ src/components/SidePanel/SidePanel.tsx | 206 ++----- src/locales/en.json | 73 ++- src/locales/fr.json | 73 ++- 14 files changed, 1596 insertions(+), 161 deletions(-) rename src/components/{Home/HomeCardMenu.tsx => ActionMenu/ActionMenu.tsx} (94%) create mode 100644 src/components/NoteBubble/NoteBubble.tsx create mode 100644 src/components/PickMenu/PickMenu.tsx create mode 100644 src/components/SidePanel/ColumnEditor.tsx create mode 100644 src/components/SidePanel/ColumnIcon.tsx create mode 100644 src/components/SidePanel/ColumnRow.tsx create mode 100644 src/components/SidePanel/ConstraintBadge.tsx create mode 100644 src/components/SidePanel/DefaultField.tsx create mode 100644 src/components/SidePanel/EnumValues.tsx diff --git a/src/components/Home/HomeCardMenu.tsx b/src/components/ActionMenu/ActionMenu.tsx similarity index 94% rename from src/components/Home/HomeCardMenu.tsx rename to src/components/ActionMenu/ActionMenu.tsx index 0989adb..c142cc7 100644 --- a/src/components/Home/HomeCardMenu.tsx +++ b/src/components/ActionMenu/ActionMenu.tsx @@ -3,7 +3,7 @@ import { useClickOutside } from "../../hooks/useClickOutside"; const MARGIN_PX = 8; -export interface HomeMenuItem { +export interface ActionMenuItem { label: string; hint?: string; danger?: boolean; @@ -11,15 +11,15 @@ export interface HomeMenuItem { onSelect: () => void; } -interface HomeCardMenuProps { +interface ActionMenuProps { x: number; y: number; label: string; - items: HomeMenuItem[]; + items: ActionMenuItem[]; onClose: () => void; } -export function HomeCardMenu({ x, y, label, items, onClose }: HomeCardMenuProps) { +export function ActionMenu({ x, y, label, items, onClose }: ActionMenuProps) { const ref = useRef(null); useClickOutside(ref, onClose); diff --git a/src/components/Home/Home.tsx b/src/components/Home/Home.tsx index 0c0923d..3318258 100644 --- a/src/components/Home/Home.tsx +++ b/src/components/Home/Home.tsx @@ -11,7 +11,7 @@ import { import { Toolbar } from "../Toolbar/Toolbar"; import { ImportModal } from "../ImportModal/ImportModal"; import { HomeCard } from "./HomeCard"; -import type { HomeMenuItem } from "./HomeCardMenu"; +import type { ActionMenuItem } from "../ActionMenu/ActionMenu"; import { HomeEmpty } from "./HomeEmpty"; import { MissingFiles } from "./MissingFiles"; import { RenameSchemaModal } from "./RenameSchemaModal"; @@ -115,7 +115,7 @@ export function Home({ onOpenSettings }: HomeProps) { .filter(Boolean) .join(" · "); - const entryMenu = (entry: HomeEntry, isCurrent: boolean): HomeMenuItem[] => [ + const entryMenu = (entry: HomeEntry, isCurrent: boolean): ActionMenuItem[] => [ { label: t("home.action.open"), onSelect: () => openHomeEntry(entry.path) }, { label: t(REVEAL_KEY), onSelect: () => void revealHomeEntry(entry.path) }, ...(entry.state === "present" diff --git a/src/components/Home/HomeCard.tsx b/src/components/Home/HomeCard.tsx index 69f30f5..a8b65e9 100644 --- a/src/components/Home/HomeCard.tsx +++ b/src/components/Home/HomeCard.tsx @@ -2,7 +2,7 @@ import { useState, type MouseEvent } from "react"; import { DotsThreeIcon as DotsThree } from "@phosphor-icons/react"; import type { DbType, Schema } from "../../types/schema"; import { useInView } from "../../hooks/useInView"; -import { HomeCardMenu, type HomeMenuItem } from "./HomeCardMenu"; +import { ActionMenu, type ActionMenuItem } from "../ActionMenu/ActionMenu"; import { SchemaPreview } from "./SchemaPreview"; const MENU_WIDTH = 224; @@ -20,7 +20,7 @@ interface HomeCardProps { badge: { label: string; tone: "accent" | "muted" } | null; openLabel: string; menuLabel: string; - menuItems: HomeMenuItem[]; + menuItems: ActionMenuItem[]; onOpen: () => void; } @@ -112,7 +112,7 @@ export function HomeCard({ )} {menu && ( - void; + onClose: () => void; +} + +// The note is edited here only, never shown inline: the panel stays compact +// and the canvas shows a note icon whose hover reads it. One edit is one undo +// step, so the text is committed on blur and on close, not per keystroke. +export function NoteBubble({ + name, + note, + placeholder, + anchor, + below = anchor, + frame, + onCommit, + onClose, +}: NoteBubbleProps) { + const { t } = useTranslation(); + const ref = useRef(null); + const textRef = useRef(null); + const [draft, setDraft] = useState(note); + const draftRef = useRef(note); + // Blur and the outside mousedown both commit; the second must not add a + // duplicate undo step. + const savedRef = useRef(note); + + const commit = () => { + const next = draftRef.current.trim() ? draftRef.current : ""; + if (next === savedRef.current) return; + savedRef.current = next; + onCommit(next); + }; + const commitRef = useRef(commit); + useEffect(() => { + commitRef.current = commit; + }); + + useLayoutEffect(() => { + const bubble = ref.current; + if (!bubble) return; + const a = below.getBoundingClientRect(); + const f = frame.getBoundingClientRect(); + const host = bubble.offsetParent?.getBoundingClientRect() ?? f; + bubble.style.top = `${a.bottom - host.top + 6}px`; + bubble.style.left = `${f.left - host.left + 8}px`; + bubble.style.width = `${f.width - 16}px`; + }, [below, frame]); + + useEffect(() => { + const text = textRef.current; + if (!text) return; + text.focus(); + text.setSelectionRange(text.value.length, text.value.length); + }, []); + + useEffect(() => { + const text = textRef.current; + if (!text) return; + text.style.height = "auto"; + text.style.height = `${Math.max(52, text.scrollHeight + 2)}px`; + }, [draft]); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (ref.current?.contains(target) || anchor.contains(target)) return; + commitRef.current(); + onClose(); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [anchor, onClose]); + + return ( +
{ + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + commit(); + onClose(); + anchor.focus(); + }} + className="absolute z-30 rounded-lg border border-border bg-surface p-2.5 shadow-float" + > +
+ }} + /> +
+