futonic 0.0.0-canary.03da5f8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -0
- package/dist/chunk-eqeddyek.js +130 -0
- package/dist/chunk-nj3a3jzm.js +26 -0
- package/dist/chunk-s3j6ndfy.js +96 -0
- package/dist/chunk-s9sdjw1m.js +181 -0
- package/dist/chunk-v0bahtg2.js +4 -0
- package/dist/cli/generators/drizzle.d.ts +11 -0
- package/dist/cli/generators/drizzle.d.ts.map +1 -0
- package/dist/cli/generators/kysely.d.ts +17 -0
- package/dist/cli/generators/kysely.d.ts.map +1 -0
- package/dist/cli/generators/prisma.d.ts +10 -0
- package/dist/cli/generators/prisma.d.ts.map +1 -0
- package/dist/cli/generators/types.d.ts +24 -0
- package/dist/cli/generators/types.d.ts.map +1 -0
- package/dist/cli/index.d.ts +28 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +100 -0
- package/dist/client/index.d.ts +15 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +7 -0
- package/dist/core/context.d.ts +23 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/host.d.ts +14 -0
- package/dist/core/host.d.ts.map +1 -0
- package/dist/core/service.d.ts +25 -0
- package/dist/core/service.d.ts.map +1 -0
- package/dist/db/internal-adapter.d.ts +63 -0
- package/dist/db/internal-adapter.d.ts.map +1 -0
- package/dist/db/kysely-factory.d.ts +34 -0
- package/dist/db/kysely-factory.d.ts.map +1 -0
- package/dist/db/schema.d.ts +36 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +337 -0
- package/dist/router/endpoint.d.ts +105 -0
- package/dist/router/endpoint.d.ts.map +1 -0
- package/dist/router/middleware.d.ts +10 -0
- package/dist/router/middleware.d.ts.map +1 -0
- package/dist/test-utils.d.ts +25 -0
- package/dist/test-utils.d.ts.map +1 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Futonic 🛋️
|
|
2
|
+
|
|
3
|
+
**A framework for building services that embed into host applications instead of deploying alongside them.**
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
There are plenty of services available as easy-to-deploy containers — auth servers, payment processors, observability tools. But for most applications, deploying multiple containers (one for the main app plus one for each service) is a waste. An auth server, a payment webhook handler, and an observability stack don't each need their own process. They could share compute and a database without any meaningful noisy-neighbor issues — because let's be honest, most apps these days are just wrappers around heavier services anyway.
|
|
8
|
+
|
|
9
|
+
## The idea
|
|
10
|
+
|
|
11
|
+
Futonic is heavily inspired by [better-auth](https://github.com/better-auth/better-auth)'s service embedding paradigm — the insight that many services don't need their own process, their own deployment, or even their own database. They just need a place to crash.
|
|
12
|
+
|
|
13
|
+
Futonic is a framework for building **embeddable services** that crash on a host application's futon. They share the host's compute. They share the host's database. They wake up when needed and stay out of the way when they're not.
|
|
14
|
+
|
|
15
|
+
No separate containers. No extra Dockerfiles. No internal networking. Just services that live inside the host app — and a great DX for building them.
|
|
16
|
+
|
|
17
|
+
## Why build embeddable services
|
|
18
|
+
|
|
19
|
+
**Your users save money:**
|
|
20
|
+
Most apps aren't mega-scale. They're tools, SaaS products, and indie projects where every dollar of infrastructure counts. When your service embeds directly into the host, your users don't need to pay for another container sitting idle 99% of the time.
|
|
21
|
+
|
|
22
|
+
**Your users get a better dev experience:**
|
|
23
|
+
No more `docker-compose up` with 6 services just to work on a feature. No more debugging why the auth container can't talk to the payments container on a laptop. Embedded services run in-process. Switch branches, switch worktrees — everything just works.
|
|
24
|
+
|
|
25
|
+
**Simplicity sells:**
|
|
26
|
+
Fewer moving parts means fewer things that break at 2am. One deploy. One database. One set of logs. Developers can always decompose later if they outgrow it — but most apps never will. The easier your service is to adopt, the more people will use it.
|
|
27
|
+
|
|
28
|
+
## How it works: the vertical slice
|
|
29
|
+
|
|
30
|
+
A futonic service is a **vertical slice** of your application. It defines everything from the client interface down to the database — API endpoints, input validation, business logic, and table schemas — as a single, self-contained unit.
|
|
31
|
+
|
|
32
|
+
```mermaid
|
|
33
|
+
block-beta
|
|
34
|
+
columns 3
|
|
35
|
+
block:host["Host Application"]:3
|
|
36
|
+
columns 3
|
|
37
|
+
space:3
|
|
38
|
+
block:app["Your App Code"]:1
|
|
39
|
+
columns 1
|
|
40
|
+
appRoutes["Routes"]
|
|
41
|
+
appLogic["Logic"]
|
|
42
|
+
appTables["Tables"]
|
|
43
|
+
end
|
|
44
|
+
block:billing["@acme/billing"]:1
|
|
45
|
+
columns 1
|
|
46
|
+
billingAPI["Endpoints"]
|
|
47
|
+
billingLogic["Logic"]
|
|
48
|
+
billingTables["Tables"]
|
|
49
|
+
end
|
|
50
|
+
block:auth["@acme/auth"]:1
|
|
51
|
+
columns 1
|
|
52
|
+
authAPI["Endpoints"]
|
|
53
|
+
authLogic["Logic"]
|
|
54
|
+
authTables["Tables"]
|
|
55
|
+
end
|
|
56
|
+
space:3
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
style billing stroke:#22c55e,stroke-width:2px
|
|
60
|
+
style auth stroke:#22c55e,stroke-width:2px
|
|
61
|
+
style app stroke:#6366f1,stroke-width:2px
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each green block is a futonic service — a vertical slice that owns its entire stack, published as an npm package. The host application mounts them alongside its own code. They all share one process, one database, one deployment.
|
|
65
|
+
|
|
66
|
+
This is what makes futonic different from microservices. A microservice is also a vertical slice, but it pays for that independence with its own container, its own deployment pipeline, and network hops between every call. A futonic service gets the same self-containment for free:
|
|
67
|
+
|
|
68
|
+
- **No deployment cost.** It runs inside your existing process. No extra containers, no extra infrastructure, no extra cloud bill.
|
|
69
|
+
- **No networking latency.** Service calls from backend code are direct function calls — same process, same database connection pool, zero serialization.
|
|
70
|
+
- **No protocol constraints.** Endpoints are built on web standard `Request`/`Response`, so a service can return JSON, full HTML pages, server-sent event streams, file downloads — anything `Response` supports.
|
|
71
|
+
|
|
72
|
+
### What a service looks like
|
|
73
|
+
|
|
74
|
+
A service defines its database tables and API endpoints. The service author publishes this as an npm package:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// @acme/billing
|
|
78
|
+
|
|
79
|
+
import { createService, createEndpoint } from "futonic";
|
|
80
|
+
|
|
81
|
+
export const billing = createService({
|
|
82
|
+
id: "billing",
|
|
83
|
+
version: "0.1.0",
|
|
84
|
+
dependencies: { database: true },
|
|
85
|
+
dbSchema: {
|
|
86
|
+
tables: {
|
|
87
|
+
invoices: {
|
|
88
|
+
fields: {
|
|
89
|
+
id: { type: "string", primaryKey: true, required: true },
|
|
90
|
+
amount: { type: "number", required: true },
|
|
91
|
+
status: { type: "string", required: true },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Endpoints receive a `ServiceContext` via middleware — giving them scoped access to the service's database tables, config, and logger:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
export function createBillingEndpoints(use: Middleware[]) {
|
|
103
|
+
return {
|
|
104
|
+
createInvoice: createEndpoint(
|
|
105
|
+
"/invoices",
|
|
106
|
+
{ method: "POST", use, body: z.object({ amount: z.number(), status: z.string() }) },
|
|
107
|
+
async (ctx) => {
|
|
108
|
+
const svc = ctx.context.serviceCtx;
|
|
109
|
+
const invoice = await svc.db.invoices.create({
|
|
110
|
+
id: crypto.randomUUID(),
|
|
111
|
+
...ctx.body,
|
|
112
|
+
});
|
|
113
|
+
svc.logger.info(`Invoice created: ${invoice.id}`);
|
|
114
|
+
return invoice;
|
|
115
|
+
},
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The endpoint writes to `svc.db.invoices`, but the actual table in the database is `billing_invoices` — automatically prefixed with the service ID. A second service with its own `invoices` table would hit a completely separate table. Same database, no collisions.
|
|
122
|
+
|
|
123
|
+
### How the host mounts it
|
|
124
|
+
|
|
125
|
+
The host developer installs the service and wires it in. Futonic auto-detects the database driver — `pg`, `mysql2`, `better-sqlite3`, or `bun:sqlite`:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { createHost } from "futonic";
|
|
129
|
+
import { billing, createBillingRouter } from "@acme/billing";
|
|
130
|
+
|
|
131
|
+
const host = createHost({
|
|
132
|
+
database: pool, // Their existing pg.Pool, mysql2 pool, or sqlite instance
|
|
133
|
+
baseURL: "http://localhost:3000",
|
|
134
|
+
services: [
|
|
135
|
+
billing({ mount: "/api/billing" }),
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await host.init();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Then a single catch-all route in their framework of choice:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// Hono
|
|
146
|
+
app.all("/api/billing/*", (c) => billingRouter.handler(c.req.raw));
|
|
147
|
+
|
|
148
|
+
// Next.js — app/api/billing/[...path]/route.ts
|
|
149
|
+
import { toNextJsHandler } from "futonic/next";
|
|
150
|
+
export const { GET, POST, PUT, DELETE, PATCH } = toNextJsHandler(billingRouter);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
That's it. The billing service handles requests at `/api/billing/*`, stores data in the host's database under prefixed tables, and the host didn't need to understand any of the service's internals to set it up.
|
|
154
|
+
|
|
155
|
+
## More features
|
|
156
|
+
|
|
157
|
+
### Type-safe RPC from the frontend
|
|
158
|
+
|
|
159
|
+
Host developers consume your service from their frontend with a fully typed client — autocomplete, return types, and error types included:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { createClient } from "futonic/client";
|
|
163
|
+
import type { BillingRouter } from "@acme/billing";
|
|
164
|
+
|
|
165
|
+
const billing = createClient<BillingRouter>({
|
|
166
|
+
baseURL: "/api/billing",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const { data } = await billing.listInvoices();
|
|
170
|
+
const { data: invoice } = await billing.createInvoice({
|
|
171
|
+
body: { amount: 4200, status: "draft" },
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Skip networking on the backend
|
|
176
|
+
|
|
177
|
+
When the host's backend code needs to call the service, there's no HTTP round-trip. The service runs in the same process, shares the same database connection pool, and returns results directly:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
const svc = host.services.get("billing").serviceContext!;
|
|
181
|
+
const invoices = await svc.db.invoices.findMany();
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Generate migrations for any ORM
|
|
185
|
+
|
|
186
|
+
Ship a CLI so your users can generate migration files for their ORM of choice:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npx @acme/billing generate --orm=drizzle --provider=pg --out=schema.ts
|
|
190
|
+
npx @acme/billing generate --orm=prisma --provider=mysql
|
|
191
|
+
npx @acme/billing generate --orm=kysely --provider=sqlite
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Return any web standard response
|
|
195
|
+
|
|
196
|
+
Endpoints aren't limited to JSON. Serve full HTML pages for embedded UIs, server-sent event streams for real-time updates, file downloads, redirects — whatever `Response` supports. Build an entire admin dashboard that lives inside the host app.
|
|
197
|
+
|
|
198
|
+
## Things you could build
|
|
199
|
+
|
|
200
|
+
- **Auth service** — User management, sessions, OAuth flows, and permissions. Like better-auth, but packaged as an embeddable service.
|
|
201
|
+
- **Payment service** — Webhook handlers, invoice management, subscription state. A reusable Stripe integration that anyone can drop into their app.
|
|
202
|
+
- **Observability service** — An API for ingesting traces, database tables for storing them, and an embedded UI for visualizing them. Jaeger without the deployment.
|
|
203
|
+
- **Feature flags** — A flag service with an admin UI, backed by the host's existing database.
|
|
204
|
+
- **CMS** — Content management endpoints with an embedded editor interface.
|
|
205
|
+
- **Notifications** — Email/push queue management with status tracking and retry logic.
|
|
206
|
+
|
|
207
|
+
## Database support
|
|
208
|
+
|
|
209
|
+
Futonic auto-detects your database driver and works with:
|
|
210
|
+
|
|
211
|
+
- **PostgreSQL** via `pg`
|
|
212
|
+
- **MySQL** via `mysql2`
|
|
213
|
+
- **SQLite** via `better-sqlite3` or Bun's built-in `bun:sqlite`
|
|
214
|
+
|
|
215
|
+
Your service works with whatever database the host is already running — no extra configuration on your end.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__require
|
|
3
|
+
} from "./chunk-v0bahtg2.js";
|
|
4
|
+
|
|
5
|
+
// src/cli/generators/prisma.ts
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
var generatePrismaSchema = async ({
|
|
10
|
+
tables,
|
|
11
|
+
provider: dbProvider,
|
|
12
|
+
file
|
|
13
|
+
}) => {
|
|
14
|
+
const provider = dbProvider === "pg" ? "postgresql" : dbProvider === "mysql" ? "mysql" : "sqlite";
|
|
15
|
+
const filePath = file || "./prisma/schema.prisma";
|
|
16
|
+
const schemaExists = existsSync(path.resolve(filePath));
|
|
17
|
+
let produceSchema;
|
|
18
|
+
try {
|
|
19
|
+
({ produceSchema } = await import("@mrleebo/prisma-ast"));
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error("@mrleebo/prisma-ast is required for Prisma schema generation. Install it with: npm install @mrleebo/prisma-ast");
|
|
22
|
+
}
|
|
23
|
+
let schemaPrisma = "";
|
|
24
|
+
if (schemaExists) {
|
|
25
|
+
schemaPrisma = await readFile(path.resolve(filePath), "utf-8");
|
|
26
|
+
} else {
|
|
27
|
+
schemaPrisma = getNewPrisma(provider);
|
|
28
|
+
}
|
|
29
|
+
const schema = produceSchema(schemaPrisma, (builder) => {
|
|
30
|
+
for (const [, table] of tables) {
|
|
31
|
+
const modelName = toPascalCase(table.prefixedName);
|
|
32
|
+
const prismaModel = builder.findByType("model", {
|
|
33
|
+
name: modelName
|
|
34
|
+
});
|
|
35
|
+
if (!prismaModel) {
|
|
36
|
+
builder.model(modelName).field("id", "String").attribute("id");
|
|
37
|
+
}
|
|
38
|
+
for (const [fieldName, field] of Object.entries(table.fields)) {
|
|
39
|
+
if (fieldName === "id")
|
|
40
|
+
continue;
|
|
41
|
+
if (prismaModel) {
|
|
42
|
+
const existing = builder.findByType("field", {
|
|
43
|
+
name: fieldName,
|
|
44
|
+
within: prismaModel.properties
|
|
45
|
+
});
|
|
46
|
+
if (existing)
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const prismaType = getPrismaType(field, provider);
|
|
50
|
+
const fieldBuilder = builder.model(modelName).field(fieldName, prismaType);
|
|
51
|
+
if (field.primaryKey) {
|
|
52
|
+
fieldBuilder.attribute("id");
|
|
53
|
+
}
|
|
54
|
+
if (field.unique) {
|
|
55
|
+
builder.model(modelName).blockAttribute(`unique([${fieldName}])`);
|
|
56
|
+
}
|
|
57
|
+
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
|
58
|
+
if (typeof field.defaultValue === "string") {
|
|
59
|
+
fieldBuilder.attribute(`default("${field.defaultValue}")`);
|
|
60
|
+
} else if (typeof field.defaultValue === "boolean" || typeof field.defaultValue === "number") {
|
|
61
|
+
fieldBuilder.attribute(`default(${field.defaultValue})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (field.references) {
|
|
65
|
+
const referencedTable = `${table.serviceId}_${field.references.model}`;
|
|
66
|
+
const refModelName = toPascalCase(referencedTable);
|
|
67
|
+
let action = "Cascade";
|
|
68
|
+
if (field.references.onDelete === "restrict")
|
|
69
|
+
action = "Restrict";
|
|
70
|
+
else if (field.references.onDelete === "set-null")
|
|
71
|
+
action = "SetNull";
|
|
72
|
+
const relationAttr = `relation(fields: [${fieldName}], references: [${field.references.field}], onDelete: ${action})`;
|
|
73
|
+
builder.model(modelName).field(referencedTable.toLowerCase(), `${refModelName}${field.required ? "" : "?"}`).attribute(relationAttr);
|
|
74
|
+
}
|
|
75
|
+
if (provider === "mysql" && field.type === "string" && !field.unique && !field.references) {
|
|
76
|
+
fieldBuilder.attribute("db.Text");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const hasMapAttr = builder.findByType("attribute", {
|
|
80
|
+
name: "map",
|
|
81
|
+
within: prismaModel?.properties
|
|
82
|
+
});
|
|
83
|
+
if (!hasMapAttr) {
|
|
84
|
+
builder.model(modelName).blockAttribute("map", table.prefixedName);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const schemaChanged = schema.trim() !== schemaPrisma.trim();
|
|
89
|
+
return {
|
|
90
|
+
code: schemaChanged ? schema : "",
|
|
91
|
+
fileName: filePath,
|
|
92
|
+
overwrite: schemaExists && schemaChanged
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
function getPrismaType(field, provider) {
|
|
96
|
+
const optional = !field.required && !field.primaryKey ? "?" : "";
|
|
97
|
+
switch (field.type) {
|
|
98
|
+
case "string":
|
|
99
|
+
return `String${optional}`;
|
|
100
|
+
case "number":
|
|
101
|
+
return `Int${optional}`;
|
|
102
|
+
case "boolean":
|
|
103
|
+
return `Boolean${optional}`;
|
|
104
|
+
case "date":
|
|
105
|
+
return `DateTime${optional}`;
|
|
106
|
+
case "json":
|
|
107
|
+
if (provider === "sqlite" || provider === "mysql") {
|
|
108
|
+
return `String${optional}`;
|
|
109
|
+
}
|
|
110
|
+
return `Json${optional}`;
|
|
111
|
+
default:
|
|
112
|
+
return `String${optional}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function toPascalCase(str) {
|
|
116
|
+
return str.split("_").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
117
|
+
}
|
|
118
|
+
function getNewPrisma(provider) {
|
|
119
|
+
return `generator client {
|
|
120
|
+
provider = "prisma-client-js"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
datasource db {
|
|
124
|
+
provider = "${provider}"
|
|
125
|
+
url = ${provider === "sqlite" ? `"file:./dev.db"` : `env("DATABASE_URL")`}
|
|
126
|
+
}`;
|
|
127
|
+
}
|
|
128
|
+
export {
|
|
129
|
+
generatePrismaSchema
|
|
130
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/db/schema.ts
|
|
2
|
+
function getServiceTables(services) {
|
|
3
|
+
const result = new Map;
|
|
4
|
+
for (const service of services) {
|
|
5
|
+
if (!service.dbSchema)
|
|
6
|
+
continue;
|
|
7
|
+
for (const [tableName, tableDef] of Object.entries(service.dbSchema.tables)) {
|
|
8
|
+
const prefixedName = `${service.id}_${tableName}`;
|
|
9
|
+
if (result.has(prefixedName)) {
|
|
10
|
+
throw new Error(`Table name collision: "${prefixedName}" is defined by multiple services`);
|
|
11
|
+
}
|
|
12
|
+
result.set(prefixedName, {
|
|
13
|
+
originalName: tableName,
|
|
14
|
+
prefixedName,
|
|
15
|
+
serviceId: service.id,
|
|
16
|
+
fields: tableDef.fields
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
function prefixTableName(serviceId, tableName) {
|
|
23
|
+
return `${serviceId}_${tableName}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { getServiceTables, prefixTableName };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import"./chunk-v0bahtg2.js";
|
|
2
|
+
|
|
3
|
+
// src/cli/generators/kysely.ts
|
|
4
|
+
var generateKyselySchema = async ({
|
|
5
|
+
tables,
|
|
6
|
+
provider,
|
|
7
|
+
file
|
|
8
|
+
}) => {
|
|
9
|
+
const statements = [];
|
|
10
|
+
for (const [, table] of tables) {
|
|
11
|
+
statements.push(tableToSQL(table, provider));
|
|
12
|
+
}
|
|
13
|
+
const code = statements.join(`
|
|
14
|
+
|
|
15
|
+
`);
|
|
16
|
+
return {
|
|
17
|
+
code: code.trim() || "",
|
|
18
|
+
fileName: file || `./futonic_migrations/${new Date().toISOString().replace(/:/g, "-")}.sql`
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
function tableToSQL(table, provider = "pg") {
|
|
22
|
+
const columns = [];
|
|
23
|
+
for (const [fieldName, field] of Object.entries(table.fields)) {
|
|
24
|
+
columns.push(` ${fieldName} ${fieldToSQLType(field, provider)}`);
|
|
25
|
+
}
|
|
26
|
+
for (const [fieldName, field] of Object.entries(table.fields)) {
|
|
27
|
+
if (field.references) {
|
|
28
|
+
const onDelete = field.references.onDelete ?? "restrict";
|
|
29
|
+
const action = onDelete === "set-null" ? "SET NULL" : onDelete.toUpperCase();
|
|
30
|
+
const referencedTable = `${table.serviceId}_${field.references.model}`;
|
|
31
|
+
columns.push(` FOREIGN KEY (${fieldName}) REFERENCES ${referencedTable}(${field.references.field}) ON DELETE ${action}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return `CREATE TABLE IF NOT EXISTS ${table.prefixedName} (
|
|
35
|
+
${columns.join(`,
|
|
36
|
+
`)}
|
|
37
|
+
);`;
|
|
38
|
+
}
|
|
39
|
+
function fieldToSQLType(field, provider) {
|
|
40
|
+
let type;
|
|
41
|
+
switch (field.type) {
|
|
42
|
+
case "string":
|
|
43
|
+
if (provider === "mysql" && (field.unique || field.references)) {
|
|
44
|
+
type = "VARCHAR(255)";
|
|
45
|
+
} else {
|
|
46
|
+
type = "TEXT";
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
case "number":
|
|
50
|
+
type = provider === "mysql" ? "INT" : "INTEGER";
|
|
51
|
+
break;
|
|
52
|
+
case "boolean":
|
|
53
|
+
type = provider === "sqlite" ? "INTEGER" : "BOOLEAN";
|
|
54
|
+
break;
|
|
55
|
+
case "date":
|
|
56
|
+
type = provider === "sqlite" ? "INTEGER" : "TIMESTAMP";
|
|
57
|
+
break;
|
|
58
|
+
case "json":
|
|
59
|
+
if (provider === "pg")
|
|
60
|
+
type = "JSONB";
|
|
61
|
+
else if (provider === "mysql")
|
|
62
|
+
type = "JSON";
|
|
63
|
+
else
|
|
64
|
+
type = "TEXT";
|
|
65
|
+
break;
|
|
66
|
+
case "binary":
|
|
67
|
+
if (provider === "pg")
|
|
68
|
+
type = "BYTEA";
|
|
69
|
+
else if (provider === "mysql")
|
|
70
|
+
type = "LONGBLOB";
|
|
71
|
+
else
|
|
72
|
+
type = "BLOB";
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
type = "TEXT";
|
|
76
|
+
}
|
|
77
|
+
const parts = [type];
|
|
78
|
+
if (field.primaryKey)
|
|
79
|
+
parts.push("PRIMARY KEY");
|
|
80
|
+
if (field.required)
|
|
81
|
+
parts.push("NOT NULL");
|
|
82
|
+
if (field.unique)
|
|
83
|
+
parts.push("UNIQUE");
|
|
84
|
+
if (field.defaultValue !== undefined) {
|
|
85
|
+
if (typeof field.defaultValue === "string") {
|
|
86
|
+
parts.push(`DEFAULT '${field.defaultValue}'`);
|
|
87
|
+
} else {
|
|
88
|
+
parts.push(`DEFAULT ${field.defaultValue}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return parts.join(" ");
|
|
92
|
+
}
|
|
93
|
+
export {
|
|
94
|
+
tableToSQL,
|
|
95
|
+
generateKyselySchema
|
|
96
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import"./chunk-v0bahtg2.js";
|
|
2
|
+
|
|
3
|
+
// src/cli/generators/drizzle.ts
|
|
4
|
+
var generateDrizzleSchema = async ({
|
|
5
|
+
tables,
|
|
6
|
+
provider,
|
|
7
|
+
file
|
|
8
|
+
}) => {
|
|
9
|
+
const filePath = file || "./futonic-schema.ts";
|
|
10
|
+
let code = generateImport({ provider, tables });
|
|
11
|
+
for (const [, table] of tables) {
|
|
12
|
+
code += `
|
|
13
|
+
${generateTable(table, provider)}
|
|
14
|
+
`;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
code,
|
|
18
|
+
fileName: filePath
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
function generateTable(table, provider) {
|
|
22
|
+
const tableFunc = `${provider}Table`;
|
|
23
|
+
const fields = table.fields;
|
|
24
|
+
const indexes = [];
|
|
25
|
+
const columnDefs = Object.entries(fields).map(([fieldName, field]) => {
|
|
26
|
+
let col = getColumnType(fieldName, field, provider);
|
|
27
|
+
if (field.primaryKey) {
|
|
28
|
+
col += ".primaryKey()";
|
|
29
|
+
}
|
|
30
|
+
if (field.defaultValue !== null && field.defaultValue !== undefined) {
|
|
31
|
+
if (typeof field.defaultValue === "string") {
|
|
32
|
+
col += `.default("${field.defaultValue}")`;
|
|
33
|
+
} else {
|
|
34
|
+
col += `.default(${field.defaultValue})`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (field.required) {
|
|
38
|
+
col += ".notNull()";
|
|
39
|
+
}
|
|
40
|
+
if (field.unique) {
|
|
41
|
+
col += ".unique()";
|
|
42
|
+
indexes.push({
|
|
43
|
+
type: "uniqueIndex",
|
|
44
|
+
name: `${table.prefixedName}_${fieldName}_uidx`,
|
|
45
|
+
on: fieldName
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (field.references) {
|
|
49
|
+
const onDelete = field.references.onDelete || "cascade";
|
|
50
|
+
const action = onDelete === "set-null" ? "set null" : onDelete;
|
|
51
|
+
const referencedTable = `${table.serviceId}_${field.references.model}`;
|
|
52
|
+
col += `.references(() => ${referencedTable}.${field.references.field}, { onDelete: '${action}' })`;
|
|
53
|
+
}
|
|
54
|
+
return ` ${fieldName}: ${col}`;
|
|
55
|
+
}).join(`,
|
|
56
|
+
`);
|
|
57
|
+
const indexBlock = assignIndexes(indexes);
|
|
58
|
+
return `export const ${table.prefixedName} = ${tableFunc}("${table.prefixedName}", {
|
|
59
|
+
${columnDefs}
|
|
60
|
+
}${indexBlock});`;
|
|
61
|
+
}
|
|
62
|
+
function getColumnType(name, field, provider) {
|
|
63
|
+
if (field.enum && field.enum.length > 0) {
|
|
64
|
+
const enumValues = field.enum.map((v) => `'${v}'`).join(", ");
|
|
65
|
+
const typeMap2 = {
|
|
66
|
+
sqlite: `text("${name}")`,
|
|
67
|
+
pg: `text("${name}", { enum: [${enumValues}] })`,
|
|
68
|
+
mysql: `mysqlEnum("${name}", [${enumValues}])`
|
|
69
|
+
};
|
|
70
|
+
return typeMap2[provider];
|
|
71
|
+
}
|
|
72
|
+
const typeMap = {
|
|
73
|
+
string: {
|
|
74
|
+
sqlite: `text("${name}")`,
|
|
75
|
+
pg: `text("${name}")`,
|
|
76
|
+
mysql: field.unique ? `varchar("${name}", { length: 255 })` : field.references ? `varchar("${name}", { length: 36 })` : `text("${name}")`
|
|
77
|
+
},
|
|
78
|
+
boolean: {
|
|
79
|
+
sqlite: `integer("${name}", { mode: 'boolean' })`,
|
|
80
|
+
pg: `boolean("${name}")`,
|
|
81
|
+
mysql: `boolean("${name}")`
|
|
82
|
+
},
|
|
83
|
+
number: {
|
|
84
|
+
sqlite: `integer("${name}")`,
|
|
85
|
+
pg: `integer("${name}")`,
|
|
86
|
+
mysql: `int("${name}")`
|
|
87
|
+
},
|
|
88
|
+
date: {
|
|
89
|
+
sqlite: `integer("${name}", { mode: 'timestamp_ms' })`,
|
|
90
|
+
pg: `timestamp("${name}")`,
|
|
91
|
+
mysql: `timestamp("${name}", { fsp: 3 })`
|
|
92
|
+
},
|
|
93
|
+
json: {
|
|
94
|
+
sqlite: `text("${name}", { mode: "json" })`,
|
|
95
|
+
pg: `jsonb("${name}")`,
|
|
96
|
+
mysql: `json("${name}")`
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const dbTypeMap = typeMap[field.type];
|
|
100
|
+
if (!dbTypeMap) {
|
|
101
|
+
throw new Error(`Unsupported field type '${field.type}' for field '${name}'.`);
|
|
102
|
+
}
|
|
103
|
+
return dbTypeMap[provider];
|
|
104
|
+
}
|
|
105
|
+
function assignIndexes(indexes) {
|
|
106
|
+
if (!indexes.length)
|
|
107
|
+
return "";
|
|
108
|
+
const lines = [", (table) => ["];
|
|
109
|
+
for (const index of indexes) {
|
|
110
|
+
lines.push(` ${index.type}("${index.name}").on(table.${index.on}),`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("]");
|
|
113
|
+
return lines.join(`
|
|
114
|
+
`);
|
|
115
|
+
}
|
|
116
|
+
function generateImport({
|
|
117
|
+
provider,
|
|
118
|
+
tables
|
|
119
|
+
}) {
|
|
120
|
+
const coreImports = [`${provider}Table`];
|
|
121
|
+
let hasJson = false;
|
|
122
|
+
let hasDate = false;
|
|
123
|
+
let hasBoolean = false;
|
|
124
|
+
let hasNumber = false;
|
|
125
|
+
let hasEnum = false;
|
|
126
|
+
for (const [, table] of tables) {
|
|
127
|
+
for (const field of Object.values(table.fields)) {
|
|
128
|
+
if (field.type === "json")
|
|
129
|
+
hasJson = true;
|
|
130
|
+
if (field.type === "date")
|
|
131
|
+
hasDate = true;
|
|
132
|
+
if (field.type === "boolean")
|
|
133
|
+
hasBoolean = true;
|
|
134
|
+
if (field.type === "number")
|
|
135
|
+
hasNumber = true;
|
|
136
|
+
if (field.enum && field.enum.length > 0)
|
|
137
|
+
hasEnum = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (provider === "mysql") {
|
|
141
|
+
coreImports.push("varchar", "text");
|
|
142
|
+
} else {
|
|
143
|
+
coreImports.push("text");
|
|
144
|
+
}
|
|
145
|
+
if (hasNumber) {
|
|
146
|
+
if (provider === "mysql")
|
|
147
|
+
coreImports.push("int");
|
|
148
|
+
else
|
|
149
|
+
coreImports.push("integer");
|
|
150
|
+
}
|
|
151
|
+
if (hasBoolean && provider !== "sqlite") {
|
|
152
|
+
coreImports.push("boolean");
|
|
153
|
+
} else if (hasBoolean) {
|
|
154
|
+
if (!hasNumber)
|
|
155
|
+
coreImports.push("integer");
|
|
156
|
+
}
|
|
157
|
+
if (hasDate && provider !== "sqlite") {
|
|
158
|
+
coreImports.push("timestamp");
|
|
159
|
+
} else if (hasDate && !hasNumber && !hasBoolean) {
|
|
160
|
+
coreImports.push("integer");
|
|
161
|
+
}
|
|
162
|
+
if (hasJson) {
|
|
163
|
+
if (provider === "pg")
|
|
164
|
+
coreImports.push("jsonb");
|
|
165
|
+
if (provider === "mysql")
|
|
166
|
+
coreImports.push("json");
|
|
167
|
+
}
|
|
168
|
+
if (hasEnum && provider === "mysql") {
|
|
169
|
+
coreImports.push("mysqlEnum");
|
|
170
|
+
}
|
|
171
|
+
const hasUniqueIdx = [...tables.values()].some((t) => Object.values(t.fields).some((f) => f.unique));
|
|
172
|
+
if (hasUniqueIdx) {
|
|
173
|
+
coreImports.push("uniqueIndex");
|
|
174
|
+
}
|
|
175
|
+
const dedupedImports = [...new Set(coreImports)];
|
|
176
|
+
return `import { ${dedupedImports.join(", ")} } from "drizzle-orm/${provider}-core";
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
export {
|
|
180
|
+
generateDrizzleSchema
|
|
181
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle ORM schema generator.
|
|
3
|
+
*
|
|
4
|
+
* Forked from better-auth `packages/cli/src/generators/drizzle.ts` (MIT).
|
|
5
|
+
* Adapted to read from futonic's PrefixedTable IR instead of getAuthTables().
|
|
6
|
+
* Generates pgTable/mysqlTable/sqliteTable definitions with column types,
|
|
7
|
+
* constraints, references, and indexes.
|
|
8
|
+
*/
|
|
9
|
+
import type { SchemaGenerator } from "./types";
|
|
10
|
+
export declare const generateDrizzleSchema: SchemaGenerator;
|
|
11
|
+
//# sourceMappingURL=drizzle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drizzle.d.ts","sourceRoot":"","sources":["../../../src/cli/generators/drizzle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAGX,eAAe,EACf,MAAM,SAAS,CAAC;AAEjB,eAAO,MAAM,qBAAqB,EAAE,eAiBnC,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw SQL DDL generator for Kysely / plain SQL migrations.
|
|
3
|
+
*
|
|
4
|
+
* better-auth's kysely generator delegates to `getMigrations()` which
|
|
5
|
+
* compiles DDL from their internal schema. We do the same thing here,
|
|
6
|
+
* generating CREATE TABLE statements from our PrefixedTable IR.
|
|
7
|
+
*
|
|
8
|
+
* Forked approach from better-auth `packages/cli/src/generators/kysely.ts` (MIT).
|
|
9
|
+
*/
|
|
10
|
+
import type { PrefixedTable } from "../../db/schema";
|
|
11
|
+
import type { DatabaseProvider, SchemaGenerator } from "./types";
|
|
12
|
+
export declare const generateKyselySchema: SchemaGenerator;
|
|
13
|
+
/**
|
|
14
|
+
* Converts a PrefixedTable to a CREATE TABLE SQL statement.
|
|
15
|
+
*/
|
|
16
|
+
export declare function tableToSQL(table: PrefixedTable, provider?: DatabaseProvider): string;
|
|
17
|
+
//# sourceMappingURL=kysely.d.ts.map
|