Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ isketch aims at that gap:
- **Git native.** Files diff cleanly, a CLI renders SVG with no browser, and pull requests get a
visual diff, so the design and the code stop drifting apart.
- **Start from real files.** Your existing `.drawio` and `.excalidraw` diagrams,
`docker-compose.yml`, OpenAPI and SQL DDL, with re-import that keeps your layout. And a `.drawio`
or `.excalidraw` back out whenever you want one.
`docker-compose.yml`, OpenAPI, SQL DDL, Prisma and Drizzle schemas, with re-import that keeps
your layout. And a `.drawio` or `.excalidraw` back out whenever you want one.
- **Local first.** No account, no server, works offline, shareable as a link.
- **Written for AI readers.** [/llms.txt](https://isketch.online/llms.txt) and
[/docs/format](https://isketch.online/docs/format) describe the format, the tools and the CLI
Expand Down Expand Up @@ -124,11 +124,12 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief
- **Edit as text.** Open the text pane beside the canvas and edit the diagram in the
[`.flow` format](#the-flow-format): typing redraws the canvas, and changes on the canvas rewrite
the text. Errors are listed by line, and the canvas keeps the last valid diagram meanwhile.
- **Import.** Paste or open a Mermaid flowchart, a `docker-compose.yml`, an OpenAPI spec or
SQL `CREATE TABLE` statements. Compose services
- **Import.** Paste or open a Mermaid flowchart, a `docker-compose.yml`, an OpenAPI spec,
SQL `CREATE TABLE` statements, a Prisma schema or Drizzle table definitions. Compose services
become shapes that fit their image (Postgres a database, RabbitMQ a queue), and dependencies
become connections. An OpenAPI spec becomes a map of its tags and the schemas they use, and SQL an entity diagram
with keys marked and foreign keys as labelled connections.
become connections. An OpenAPI spec becomes a map of its tags and the schemas they use, and SQL,
Prisma and Drizzle an entity diagram with keys marked and foreign keys (or relations) as labelled
connections. `isketch import` does the same from the command line.
Re-importing updates the diagram and keeps its layout and anything added by
hand. Anything skipped is listed by line.
- **draw.io, both ways.** Import a `.drawio` file, compressed or not, or the XML from Extras > Edit
Expand Down Expand Up @@ -252,6 +253,7 @@ npm run isketch -- render diagram.flow -o diagram.svg # draw it, add --dark fo
npm run isketch -- check docs/*.flow # file:line errors, exit 1 if any
npm run isketch -- diff old.flow new.flow -o diff.svg # what changed, listed and drawn
npm run isketch -- brief diagram.flow # a Markdown brief for a coding agent
npm run isketch -- import prisma/schema.prisma -o db.flow # any import format, told from the name
npm run isketch -- mcp docs # an MCP server for the diagrams in docs/
npm run examples # redraw every SVG in examples/
```
Expand Down
40 changes: 35 additions & 5 deletions backlog/tasks/fl-85 - Import-Prisma-and-Drizzle-schemas.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
id: FL-85
title: Import Prisma and Drizzle schemas
status: To Do
assignee: []
status: Done
assignee:
- '@raj-khan'
created_date: '2026-09-25 17:39'
updated_date: '2026-09-27 13:58'
labels:
- import
milestone: m-3
Expand All @@ -24,8 +26,36 @@ The schema files most TypeScript projects keep, as entity diagrams, like the SQL

<!-- AC:BEGIN -->

- [ ] #1 schema.prisma models become table shapes with their fields; relations become edges
- [ ] #2 Drizzle table definitions import the same way
- [ ] #3 Available in the import dialog and the CLI
- [x] #1 schema.prisma models become table shapes with their fields; relations become edges
- [x] #2 Drizzle table definitions import the same way
- [x] #3 Available in the import dialog and the CLI

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

1. src/domain/prisma.js: models (and views) become tables listing their fields, @id and @@id as PK; relations with fields: [...] become edges from the holder to the target labelled with the key, marking it FK; lists on both sides with no key become one two-way many-to-many edge; unknown field types are warned by line.
2. src/domain/drizzle.js: pgTable, mysqlTable and sqliteTable calls become tables named as in the database; .primaryKey() and primaryKey({ columns }) mark keys; .references(), foreignKey() and one(..., { fields }) become edges; references to unknown tables are warned.
3. Shared describeColumns with SQL; both in IMPORT_FORMATS (dialog).
4. CLI: isketch import <file> [--from <format>] [-o out.flow] for every import format, with detectFormat from name and content.
5. Unit tests for each reader, detectFormat and the CLI; e2e for both in the dialog.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

Both readers are line and brace aware rather than full parsers: comments are blanked first, and Drizzle constraints are read only inside their own table call, found by matching its parentheses (a schema without semicolons otherwise leaked one tables foreign keys into another). Prisma one-to-many back relations add nothing; implicit many-to-many is one two-way edge. Both carry an origin, so a re-import updates in place.

Verified: prisma.spec (5), drizzle.spec (4), importers.spec for detectFormat (3), CLI run.spec for isketch import (3: name detection with warnings and summary, --from with an unknown format, unreadable and undetectable files); e2e/schemas.spec.js (2, both formats through the Import dialog). e2e 140, vitest 294, lint and typecheck.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

Prisma schemas and Drizzle table definitions import as entity diagrams, with keys marked and relations as labelled edges, from the Import dialog and from a new isketch import command that handles every import format, told from the file name and content. Verified with unit, CLI and e2e tests.
<!-- SECTION:FINAL_SUMMARY:END -->
48 changes: 48 additions & 0 deletions e2e/schemas.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test'

import { fromMenu } from './helpers.js'

const shapes = (page) => page.locator('.vue-flow__node')

const PRISMA = `model User {
id Int @id
email String
posts Post[]
}

model Post {
id Int @id
author User @relation(fields: [authorId], references: [id])
authorId Int
}`

const DRIZZLE = `export const users = pgTable('users', {
id: serial('id').primaryKey(),
})

export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
authorId: integer('author_id').references(() => users.id),
})`

for (const { label, text, key } of [
{ label: 'Prisma', text: PRISMA, key: 'authorId' },
{ label: 'Drizzle', text: DRIZZLE, key: 'author_id' },
]) {
test(`draws a ${label} schema as tables, with its relations`, async ({ page }) => {
await page.goto('/new')
await expect(shapes(page)).toHaveCount(5)

await fromMenu(page, 'Import')
const dialog = page.getByRole('dialog', { name: 'Import' })
await dialog.getByRole('radio', { name: label }).click()
await dialog.getByLabel(`${label} to import`).fill(text)
await dialog.getByRole('radio', { name: /A new diagram/ }).check()
await expect(dialog.getByRole('status')).toContainText('2 shapes and 1 connection')
await dialog.getByRole('button', { name: 'Import' }).click()

await expect(shapes(page)).toHaveCount(2)
await expect(shapes(page).locator('[data-shape="table"]')).toHaveCount(2)
await expect(page.getByTestId('edge-label').filter({ hasText: key })).toBeVisible()
})
}
40 changes: 40 additions & 0 deletions src/cli/__tests__/run.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,43 @@ describe('usage', () => {
expect(await run(['render', 'a.flow', '-o'], fakeIo().io)).toBe(2)
})
})

describe('isketch import', () => {
const PRISMA =
'model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n author User @relation(fields: [authorId], references: [id])\n authorId Int\n tag Tagg\n}\n'

it('reads the format from the name, and writes .flow to a file or stdout', async () => {
const toFile = fakeIo({ 'schema.prisma': PRISMA })
expect(await run(['import', 'schema.prisma', '-o', 'db.flow'], toFile.io)).toBe(0)
expect(toFile.written['db.flow']).toContain('User = table "User" -- id PK')
expect(toFile.written['db.flow']).toContain('Post -> User : authorId')
expect(toFile.err.join('')).toContain('schema.prisma:9: Post.tag is a Tagg')
expect(toFile.err.join('')).toContain(
'Imported schema.prisma (Prisma) to db.flow: 2 shapes, 1 connection',
)

const toStdout = fakeIo({
'docker-compose.yml': 'services:\n api:\n depends_on: [db]\n db:\n image: postgres\n',
})
expect(await run(['import', 'docker-compose.yml'], toStdout.io)).toBe(0)
expect(toStdout.out.join('')).toMatch(/api -> db/)
})

it('takes the format outright with --from, and says which there are', async () => {
const named = fakeIo({ 'db.txt': 'CREATE TABLE a (id int PRIMARY KEY);' })
expect(await run(['import', '--from', 'sql', 'db.txt'], named.io)).toBe(0)
expect(named.out.join('')).toContain('a = table "a"')

const unknown = fakeIo({ 'db.txt': '' })
expect(await run(['import', 'db.txt', '--from', 'yaml'], unknown.io)).toBe(2)
expect(unknown.err.join('')).toContain('there is no "yaml" import. Try one of: mermaid')
})

it('fails when it cannot tell the format, or cannot read the file', async () => {
const vague = fakeIo({ 'notes.txt': 'hello' })
expect(await run(['import', 'notes.txt'], vague.io)).toBe(1)
expect(vague.err.join('')).toContain('Say it with --from')
expect(await run(['import', 'missing.prisma'], fakeIo().io)).toBe(1)
expect(await run(['import'], fakeIo().io)).toBe(2)
})
})
63 changes: 62 additions & 1 deletion src/cli/run.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseFlow } from '../domain/flowText.js'
import { parseFlow, serialiseFlow } from '../domain/flowText.js'
import { detectFormat, IMPORT_FORMATS, importFormat } from '../domain/importers.js'
import { describeDiff, diffDocuments, isUnchanged, mergeForDiff } from '../domain/diff.js'
import { renderSvg } from '../domain/renderSvg.js'
import { toBrief } from '../domain/brief.js'
Expand All @@ -8,6 +9,9 @@ export const USAGE = `Usage:
isketch render <file.flow> [-o <out.svg>] [--dark] Draw a diagram as SVG
isketch check <file.flow>... Report errors, exit 1 if any
isketch brief <file.flow> A Markdown brief for a coding agent
isketch import <file> [--from <format>] [-o <out.flow>]
Turn a schema, spec or drawing into .flow
(${IMPORT_FORMATS.map((format) => format.id).join(', ')})
isketch mcp [folder] An MCP server for the .flow files in a folder
isketch diff <before.flow> <after.flow> [-o <out.svg>] [--dark]
List what changed, and draw it
Expand All @@ -33,6 +37,7 @@ export async function run(argv, io) {
if (command === 'check') return check(rest, io)
if (command === 'diff') return diff(rest, io)
if (command === 'brief') return brief(rest, io)
if (command === 'import') return importFile(rest, io)

io.stderr(USAGE)
return command === undefined || command === '--help' || command === '-h' ? 0 : 2
Expand Down Expand Up @@ -118,6 +123,62 @@ async function diff(args, io) {
return 0
}

/**
* A file in any import format, as `.flow` text: to stdout, or to `-o`. The
* format is read from the name, and from the content where names are shared;
* `--from` says it outright. What was skipped is reported like a check error.
* @param {string[]} args
* @param {Parameters<typeof run>[1]} io
*/
async function importFile(args, io) {
const fromIndex = args.findIndex((arg) => arg === '--from')
const from = fromIndex === -1 ? '' : (args[fromIndex + 1] ?? '')
const { files, out } = options(
args.filter((_, index) => fromIndex === -1 || (index !== fromIndex && index !== fromIndex + 1)),
)
const [input] = files
if (!input || out === '' || (fromIndex !== -1 && !from)) {
io.stderr(USAGE)
return 2
}

let text
try {
text = await io.readFile(input)
} catch {
io.stderr(`${input}: cannot be read\n`)
return 1
}

const format = from ? importFormat(from) : detectFormat(input, text)
if (!format) {
io.stderr(
from
? `${input}: there is no "${from}" import. Try one of: ${IMPORT_FORMATS.map((each) => each.id).join(', ')}\n`
: `${input}: the format cannot be told from the name. Say it with --from.\n`,
)
return from ? 2 : 1
}

const { document, warnings } = format.read(text)
warnings.forEach(({ line, message }) =>
io.stderr(line ? `${input}:${line}: ${message}\n` : `${input}: ${message}\n`),
)
if (!document) return 1

const flow = serialiseFlow(document)
if (out) {
await io.writeFile(out, flow)
io.stderr(
`Imported ${input} (${format.label}) to ${out}: ${count(document.nodes.length, 'shape')}, ${count(document.edges.length, 'connection')}\n`,
)
} else io.stdout(flow)
return 0
}

/** @param {number} n @param {string} noun */
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`

/**
* The font to embed, read only when a sketch needs it.
* @param {import('../domain/types.js').FlowDocument} document
Expand Down
74 changes: 74 additions & 0 deletions src/domain/__tests__/drizzle.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'

import { fromDrizzle } from '../drizzle.js'

const SCHEMA = `import { integer, pgTable, primaryKey, serial, text, foreignKey } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'

// People who sign in.
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
bio: text('bio', { length: 280 }),
})

export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
authorId: integer('author_id').references(() => users.id, { onDelete: 'cascade' }),
})

export const likes = pgTable(
'likes',
{
userId: integer('user_id').notNull(),
postId: integer('post_id').notNull(),
},
(t) => [
primaryKey({ columns: [t.userId, t.postId] }),
foreignKey({ columns: [t.postId], foreignColumns: [posts.id] }),
],
)

export const comments = pgTable('comments', {
id: serial().primaryKey(),
postId: integer('post_id'),
tagId: integer('tag_id').references(() => tags.id),
})

export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, { fields: [comments.postId], references: [posts.id] }),
}))
`

describe('fromDrizzle', () => {
const { document, warnings } = fromDrizzle(SCHEMA)
const byId = Object.fromEntries(document.nodes.map((node) => [node.id, node]))

it('makes each table a table shape, named as in the database, with its columns', () => {
expect(document.nodes.map((node) => node.id)).toEqual(['users', 'posts', 'likes', 'comments'])
expect(byId.users).toMatchObject({ type: 'table', name: 'users' })
expect(byId.users.data.description).toBe('id PK, email, bio')
expect(byId.posts.data.description).toBe('id PK, title, author_id FK')
expect(byId.likes.data.description).toBe('user_id PK, post_id PK FK')
expect(byId.comments.data.description).toBe('id PK, post_id FK, tag_id')
})

it('makes references, foreign keys and relations edges, labelled with the column', () => {
expect(
document.edges.map(({ source, target, label }) => `${source}->${target}:${label}`),
).toEqual(['posts->users:author_id', 'likes->posts:post_id', 'comments->posts:post_id'])
})

it('says which references point at tables it does not have', () => {
expect(warnings).toEqual([
expect.objectContaining({
message: 'comments.tagId refers to tags, which is not defined here.',
}),
])
})

it('refuses text with no tables', () => {
expect(fromDrizzle('const x = 1').document).toBeNull()
})
})
28 changes: 28 additions & 0 deletions src/domain/__tests__/importers.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'

import { detectFormat } from '../importers.js'

const id = (name, text = '') => detectFormat(name, text)?.id ?? null

describe('detectFormat', () => {
it('knows a format from its own extension', () => {
expect(id('prisma/schema.prisma')).toBe('prisma')
expect(id('db/migrations/001.sql')).toBe('sql')
expect(id('architecture.drawio')).toBe('drawio')
expect(id('sketch.excalidraw')).toBe('excalidraw')
expect(id('flow.mmd')).toBe('mermaid')
expect(id('src/db/schema.ts')).toBe('drizzle')
})

it('tells YAML and JSON apart by what is in them', () => {
expect(id('docker-compose.yml', 'services:\n api: {}')).toBe('compose')
expect(id('openapi.yaml', 'openapi: 3.1.0\ninfo: {}')).toBe('openapi')
expect(id('spec.json', '{\n "openapi": "3.0.0"\n}')).toBe('openapi')
expect(id('board.json', '{"type":"excalidraw","elements":[]}')).toBe('excalidraw')
expect(id('package.json', '{"name":"x"}')).toBeNull()
})

it('has nothing to say about other files', () => {
expect(id('notes.txt', 'hello')).toBeNull()
})
})
Loading
Loading