bunderstack 0.1.0 → 0.2.0
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/package.json +23 -3
- package/src/auth.ts +5 -7
- package/src/config.ts +7 -2
- package/src/crud.ts +2 -3
- package/src/db.ts +100 -7
- package/src/dialect.ts +38 -0
- package/src/env.ts +4 -1
- package/src/idempotency.ts +16 -21
- package/src/index.ts +37 -39
- package/src/internal-tables-pg.ts +45 -0
- package/src/internal-tables.ts +36 -8
- package/src/list-query.ts +9 -4
- package/src/provision-internals.ts +28 -0
- package/src/provision.ts +100 -9
- package/src/schema-export-pg.ts +6 -0
- package/src/storage/delete.ts +2 -3
- package/src/storage/file-meta.ts +31 -33
- package/src/storage/router.ts +3 -3
- package/src/storage/sweep.ts +2 -3
- package/src/trpc.ts +2 -3
- package/src/typeid-pg.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backend",
|
|
@@ -33,8 +33,11 @@
|
|
|
33
33
|
"exports": {
|
|
34
34
|
".": "./src/index.ts",
|
|
35
35
|
"./access": "./src/access.ts",
|
|
36
|
+
"./provision": "./src/provision.ts",
|
|
36
37
|
"./schema": "./src/schema-export.ts",
|
|
38
|
+
"./schema/pg": "./src/schema-export-pg.ts",
|
|
37
39
|
"./typeid": "./src/typeid.ts",
|
|
40
|
+
"./typeid/pg": "./src/typeid-pg.ts",
|
|
38
41
|
"./env": "./src/env.ts",
|
|
39
42
|
"./trpc": "./src/trpc.ts"
|
|
40
43
|
},
|
|
@@ -45,25 +48,42 @@
|
|
|
45
48
|
"db:migrate": "drizzle-kit migrate"
|
|
46
49
|
},
|
|
47
50
|
"dependencies": {
|
|
48
|
-
"@libsql/client": "^0.14.0",
|
|
49
51
|
"@trpc/server": "^11.0.0",
|
|
50
52
|
"better-auth": "^1.0.0",
|
|
51
|
-
"drizzle-orm": "^0.45.0",
|
|
52
53
|
"hono": "^4.0.0",
|
|
53
54
|
"superjson": "^2.2.0",
|
|
54
55
|
"zod": "^4.4.3"
|
|
55
56
|
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@electric-sql/pglite": ">=0.3.0",
|
|
59
|
+
"@libsql/client": ">=0.14.0",
|
|
60
|
+
"drizzle-kit": "^0.30.0",
|
|
61
|
+
"drizzle-orm": "^0.45.0"
|
|
62
|
+
},
|
|
56
63
|
"peerDependencies": {
|
|
64
|
+
"@electric-sql/pglite": ">=0.3.0",
|
|
65
|
+
"@libsql/client": ">=0.14.0",
|
|
57
66
|
"drizzle-kit": "^0.30.0",
|
|
67
|
+
"drizzle-orm": "^0.45.0",
|
|
58
68
|
"nodemailer": "^6",
|
|
69
|
+
"postgres": ">=3.4.0",
|
|
59
70
|
"typescript": "^5"
|
|
60
71
|
},
|
|
61
72
|
"peerDependenciesMeta": {
|
|
73
|
+
"@electric-sql/pglite": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@libsql/client": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
62
79
|
"drizzle-kit": {
|
|
63
80
|
"optional": true
|
|
64
81
|
},
|
|
65
82
|
"nodemailer": {
|
|
66
83
|
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"postgres": {
|
|
86
|
+
"optional": true
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
89
|
}
|
package/src/auth.ts
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
-
|
|
3
1
|
// src/auth.ts
|
|
4
2
|
import { betterAuth } from 'better-auth'
|
|
5
3
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
6
4
|
|
|
7
5
|
import type { BetterAuthConfig } from './config'
|
|
8
6
|
import type { AuthSessionResolver } from './access'
|
|
7
|
+
import type { AnyDb, Dialect } from './dialect'
|
|
9
8
|
import type { EmailFacade } from './email'
|
|
10
9
|
|
|
11
|
-
export function createAuth(
|
|
12
|
-
db: LibSQLDatabase<Record<string, unknown>>,
|
|
13
|
-
cfg: BetterAuthConfig,
|
|
14
|
-
) {
|
|
10
|
+
export function createAuth(db: AnyDb, cfg: BetterAuthConfig, dialect: Dialect) {
|
|
15
11
|
return betterAuth({
|
|
16
12
|
...cfg,
|
|
17
|
-
database: drizzleAdapter(db
|
|
13
|
+
database: drizzleAdapter(db as Parameters<typeof drizzleAdapter>[0], {
|
|
14
|
+
provider: dialect === 'pg' ? 'pg' : 'sqlite',
|
|
15
|
+
}),
|
|
18
16
|
})
|
|
19
17
|
}
|
|
20
18
|
|
package/src/config.ts
CHANGED
|
@@ -24,7 +24,11 @@ export const BunderstackOptionsSchema = z.object({
|
|
|
24
24
|
schema: z.record(z.string(), z.unknown()),
|
|
25
25
|
access: z.record(z.string(), z.any()).optional(),
|
|
26
26
|
database: z
|
|
27
|
-
.object({
|
|
27
|
+
.object({
|
|
28
|
+
url: z.string().optional(),
|
|
29
|
+
authToken: z.string().optional(),
|
|
30
|
+
migrations: z.string().optional(),
|
|
31
|
+
})
|
|
28
32
|
.optional(),
|
|
29
33
|
auth: z.record(z.string(), z.unknown()).optional(),
|
|
30
34
|
// Loose: bucket access/scope hold functions that can't survive strict zod
|
|
@@ -99,7 +103,7 @@ export type BunderstackConfig<
|
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export type ResolvedConfig = {
|
|
102
|
-
database: { url: string; authToken?: string }
|
|
106
|
+
database: { url: string; authToken?: string; migrations: string }
|
|
103
107
|
auth: BetterAuthConfig
|
|
104
108
|
storage: ResolvedStorageBuckets
|
|
105
109
|
realtime?:
|
|
@@ -125,6 +129,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
125
129
|
database: {
|
|
126
130
|
url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
|
|
127
131
|
authToken: parsed.database?.authToken ?? resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
132
|
+
migrations: parsed.database?.migrations ?? './migrations',
|
|
128
133
|
},
|
|
129
134
|
auth: (() => {
|
|
130
135
|
const authInput = options.auth ?? {}
|
package/src/crud.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
-
|
|
3
1
|
import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
|
|
4
2
|
import { Hono } from 'hono'
|
|
5
3
|
|
|
4
|
+
import type { AnyDb } from './dialect'
|
|
6
5
|
import type { RealtimeBroker } from './realtime/index'
|
|
7
6
|
|
|
8
7
|
import {
|
|
@@ -61,7 +60,7 @@ async function enforce(
|
|
|
61
60
|
|
|
62
61
|
export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
63
62
|
schema: TSchema,
|
|
64
|
-
db:
|
|
63
|
+
db: AnyDb,
|
|
65
64
|
options: CrudRouterOptions,
|
|
66
65
|
): Hono {
|
|
67
66
|
const router = new Hono()
|
package/src/db.ts
CHANGED
|
@@ -1,10 +1,103 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
1
|
+
// src/db.ts — dialect/driver dispatch. Every driver module loads via dynamic
|
|
2
|
+
// import so the driver packages stay optional peers; the ignore comments keep
|
|
3
|
+
// bundlers (vite/nitro, webpack) from resolving them at build time.
|
|
4
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
5
|
+
import type { PgDatabase, PgQueryResultHKT, PgTable } from 'drizzle-orm/pg-core'
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
import { mkdir } from 'node:fs/promises'
|
|
8
|
+
|
|
9
|
+
import type { Dialect } from './dialect'
|
|
10
|
+
|
|
11
|
+
export type Driver = 'libsql' | 'pglite' | 'bun-sql' | 'postgres-js'
|
|
12
|
+
|
|
13
|
+
/** Per-dialect public db type, computed from the schema's table brands. */
|
|
14
|
+
export type DbFor<TSchema extends Record<string, unknown>> = [
|
|
15
|
+
Extract<TSchema[keyof TSchema], PgTable>,
|
|
16
|
+
] extends [never]
|
|
17
|
+
? LibSQLDatabase<TSchema>
|
|
18
|
+
: PgDatabase<PgQueryResultHKT, TSchema>
|
|
19
|
+
|
|
20
|
+
const PG_SERVER_RE = /^postgres(ql)?:\/\//
|
|
21
|
+
const LIBSQL_REMOTE_RE = /^(libsql|wss?|https?):\/\//
|
|
22
|
+
|
|
23
|
+
/** Exported for testing the missing-package error wrapping in isolation. */
|
|
24
|
+
export async function importDriver<T>(specifier: string, hint: string): Promise<T> {
|
|
25
|
+
try {
|
|
26
|
+
return (await import(
|
|
27
|
+
/* @vite-ignore */ /* webpackIgnore: true */ specifier
|
|
28
|
+
)) as T
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
throw new Error(`[bunderstack] ${hint}`, { cause })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function createDb<TSchema extends Record<string, unknown>>(
|
|
5
35
|
schema: TSchema,
|
|
6
|
-
cfg: { url: string; authToken?: string },
|
|
7
|
-
) {
|
|
8
|
-
|
|
9
|
-
|
|
36
|
+
cfg: { url: string; authToken?: string; dialect: Dialect },
|
|
37
|
+
): Promise<{ db: DbFor<TSchema>; driver: Driver }> {
|
|
38
|
+
if (cfg.dialect === 'sqlite') {
|
|
39
|
+
if (PG_SERVER_RE.test(cfg.url)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'[bunderstack] DATABASE_URL is a Postgres URL but the schema uses sqliteTable. ' +
|
|
42
|
+
'Define the schema with drizzle-orm/pg-core, or point DATABASE_URL at a SQLite database.',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
const { drizzle } = await importDriver<typeof import('drizzle-orm/libsql')>(
|
|
46
|
+
'drizzle-orm/libsql',
|
|
47
|
+
'SQLite support requires @libsql/client, which is not installed.\n' +
|
|
48
|
+
' Run `bun add @libsql/client`.',
|
|
49
|
+
)
|
|
50
|
+
const db = drizzle({
|
|
51
|
+
connection: { url: cfg.url, authToken: cfg.authToken },
|
|
52
|
+
schema,
|
|
53
|
+
})
|
|
54
|
+
return { db: db as unknown as DbFor<TSchema>, driver: 'libsql' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (LIBSQL_REMOTE_RE.test(cfg.url)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'[bunderstack] DATABASE_URL looks like a libsql/Turso URL but the schema uses pgTable. ' +
|
|
60
|
+
'Set DATABASE_URL=postgres://… (or leave it unset for local PGlite).',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (PG_SERVER_RE.test(cfg.url)) {
|
|
65
|
+
if (typeof Bun !== 'undefined') {
|
|
66
|
+
const { drizzle } = await import(
|
|
67
|
+
/* @vite-ignore */ /* webpackIgnore: true */ 'drizzle-orm/bun-sql'
|
|
68
|
+
)
|
|
69
|
+
return {
|
|
70
|
+
db: drizzle(cfg.url, { schema }) as unknown as DbFor<TSchema>,
|
|
71
|
+
driver: 'bun-sql',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { drizzle } = await importDriver<
|
|
75
|
+
typeof import('drizzle-orm/postgres-js')
|
|
76
|
+
>(
|
|
77
|
+
'drizzle-orm/postgres-js',
|
|
78
|
+
'Postgres on Node requires the `postgres` driver, which is not installed.\n' +
|
|
79
|
+
' Run `npm install postgres`. (Under Bun the built-in Bun.sql is used instead.)',
|
|
80
|
+
)
|
|
81
|
+
return {
|
|
82
|
+
db: drizzle(cfg.url, { schema }) as unknown as DbFor<TSchema>,
|
|
83
|
+
driver: 'postgres-js',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Local PGlite: `file:<dir>`, a bare path, `:memory:`, or `memory://`.
|
|
88
|
+
const raw = cfg.url.startsWith('file:') ? cfg.url.slice('file:'.length) : cfg.url
|
|
89
|
+
const dataDir = raw === ':memory:' ? 'memory://' : raw
|
|
90
|
+
if (!dataDir.startsWith('memory://')) {
|
|
91
|
+
await mkdir(dataDir, { recursive: true })
|
|
92
|
+
}
|
|
93
|
+
const { drizzle } = await importDriver<typeof import('drizzle-orm/pglite')>(
|
|
94
|
+
'drizzle-orm/pglite',
|
|
95
|
+
'Local Postgres development requires PGlite, which is not installed.\n' +
|
|
96
|
+
' Run `bun add -d @electric-sql/pglite` — bunderstack runs an embedded Postgres in ./data.pglite.\n' +
|
|
97
|
+
' In production set DATABASE_URL=postgres://… (PGlite is not needed there).',
|
|
98
|
+
)
|
|
99
|
+
return {
|
|
100
|
+
db: drizzle(dataDir, { schema }) as unknown as DbFor<TSchema>,
|
|
101
|
+
driver: 'pglite',
|
|
102
|
+
}
|
|
10
103
|
}
|
package/src/dialect.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/dialect.ts — schema-driven dialect detection. Imports only dialect-core
|
|
2
|
+
// drizzle entrypoints (no drivers), safe in every module graph.
|
|
3
|
+
import { is } from 'drizzle-orm'
|
|
4
|
+
import { PgTable } from 'drizzle-orm/pg-core'
|
|
5
|
+
import { SQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
6
|
+
|
|
7
|
+
export type Dialect = 'sqlite' | 'pg'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal structural view of a drizzle db shared by both dialects. Internal
|
|
11
|
+
* modules run dynamic tables (Record<string, unknown> schemas) where drizzle's
|
|
12
|
+
* generics add no safety, so they accept this instead of a per-dialect union.
|
|
13
|
+
* The public surface (`app.db`, tRPC ctx) keeps full per-dialect typing via
|
|
14
|
+
* `DbFor` in db.ts.
|
|
15
|
+
*/
|
|
16
|
+
export type AnyDb = {
|
|
17
|
+
select: (...args: any[]) => any
|
|
18
|
+
insert: (...args: any[]) => any
|
|
19
|
+
update: (...args: any[]) => any
|
|
20
|
+
delete: (...args: any[]) => any
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Classify a schema by its table brands. Mixed dialects are a config error. */
|
|
24
|
+
export function detectDialect(schema: Record<string, unknown>): Dialect {
|
|
25
|
+
let pgKey: string | undefined
|
|
26
|
+
let sqliteKey: string | undefined
|
|
27
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
28
|
+
if (is(value, PgTable)) pgKey ??= key
|
|
29
|
+
else if (is(value, SQLiteTable)) sqliteKey ??= key
|
|
30
|
+
}
|
|
31
|
+
if (pgKey !== undefined && sqliteKey !== undefined) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`[bunderstack] schema mixes dialects: "${pgKey}" is a Postgres table while "${sqliteKey}" is a SQLite table. ` +
|
|
34
|
+
'Define every table with the same dialect (drizzle-orm/pg-core or drizzle-orm/sqlite-core).',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
return pgKey !== undefined ? 'pg' : 'sqlite'
|
|
38
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type ValidateEnvOptions = {
|
|
|
49
49
|
emailProvider?: string
|
|
50
50
|
/** Value source; defaults to process.env. Tests pass this explicitly. */
|
|
51
51
|
source?: Record<string, string | undefined>
|
|
52
|
+
/** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
|
|
53
|
+
defaultDatabaseUrl?: string
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
|
|
@@ -96,7 +98,8 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
|
|
|
96
98
|
|
|
97
99
|
const base: BaseEnv = {
|
|
98
100
|
NODE_ENV: source.NODE_ENV,
|
|
99
|
-
DATABASE_URL:
|
|
101
|
+
DATABASE_URL:
|
|
102
|
+
source.DATABASE_URL ?? options.defaultDatabaseUrl ?? 'file:./data.db',
|
|
100
103
|
DATABASE_AUTH_TOKEN: source.DATABASE_AUTH_TOKEN,
|
|
101
104
|
AUTH_SECRET: source.AUTH_SECRET ?? DEV_AUTH_SECRET,
|
|
102
105
|
REDIS_URL: source.REDIS_URL,
|
package/src/idempotency.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
-
|
|
3
1
|
import { and, eq, lt } from 'drizzle-orm'
|
|
4
2
|
import { createHash } from 'node:crypto'
|
|
5
3
|
|
|
6
|
-
import {
|
|
4
|
+
import type { AnyDb } from './dialect'
|
|
5
|
+
|
|
6
|
+
import { idempotencyTableFor } from './internal-tables'
|
|
7
7
|
|
|
8
8
|
export type IdempotencyConfig = {
|
|
9
9
|
ttlMs?: number
|
|
@@ -21,34 +21,28 @@ export type IdempotencyLookup =
|
|
|
21
21
|
| { type: 'proceed' }
|
|
22
22
|
|
|
23
23
|
export async function lookupIdempotency(
|
|
24
|
-
db:
|
|
24
|
+
db: AnyDb,
|
|
25
25
|
tableName: string,
|
|
26
26
|
key: string,
|
|
27
27
|
body: string,
|
|
28
28
|
config: IdempotencyConfig,
|
|
29
29
|
): Promise<IdempotencyLookup> {
|
|
30
|
+
const t = idempotencyTableFor(db)
|
|
30
31
|
const now = Date.now()
|
|
31
32
|
|
|
32
33
|
// TTL sweep: drop expired rows before reading.
|
|
33
|
-
await db
|
|
34
|
-
.delete(bunderstackIdempotency)
|
|
35
|
-
.where(lt(bunderstackIdempotency.expiresAt, now))
|
|
34
|
+
await db.delete(t).where(lt(t.expiresAt, now))
|
|
36
35
|
|
|
37
36
|
const bodyHash = hashBody(body)
|
|
38
37
|
const rows = await db
|
|
39
38
|
.select({
|
|
40
|
-
bodyHash:
|
|
41
|
-
status:
|
|
42
|
-
response:
|
|
43
|
-
expiresAt:
|
|
39
|
+
bodyHash: t.bodyHash,
|
|
40
|
+
status: t.status,
|
|
41
|
+
response: t.response,
|
|
42
|
+
expiresAt: t.expiresAt,
|
|
44
43
|
})
|
|
45
|
-
.from(
|
|
46
|
-
.where(
|
|
47
|
-
and(
|
|
48
|
-
eq(bunderstackIdempotency.key, key),
|
|
49
|
-
eq(bunderstackIdempotency.tableName, tableName),
|
|
50
|
-
),
|
|
51
|
-
)
|
|
44
|
+
.from(t)
|
|
45
|
+
.where(and(eq(t.key, key), eq(t.tableName, tableName)))
|
|
52
46
|
.limit(1)
|
|
53
47
|
|
|
54
48
|
const row = rows[0]
|
|
@@ -64,7 +58,7 @@ export async function lookupIdempotency(
|
|
|
64
58
|
}
|
|
65
59
|
|
|
66
60
|
export async function storeIdempotency(
|
|
67
|
-
db:
|
|
61
|
+
db: AnyDb,
|
|
68
62
|
tableName: string,
|
|
69
63
|
key: string,
|
|
70
64
|
body: string,
|
|
@@ -72,13 +66,14 @@ export async function storeIdempotency(
|
|
|
72
66
|
response: unknown,
|
|
73
67
|
config: IdempotencyConfig,
|
|
74
68
|
): Promise<void> {
|
|
69
|
+
const t = idempotencyTableFor(db)
|
|
75
70
|
const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS
|
|
76
71
|
const expiresAt = Date.now() + ttlMs
|
|
77
72
|
const bodyHash = hashBody(body)
|
|
78
73
|
const responseText = JSON.stringify(response)
|
|
79
74
|
|
|
80
75
|
await db
|
|
81
|
-
.insert(
|
|
76
|
+
.insert(t)
|
|
82
77
|
.values({
|
|
83
78
|
key,
|
|
84
79
|
tableName,
|
|
@@ -88,7 +83,7 @@ export async function storeIdempotency(
|
|
|
88
83
|
expiresAt,
|
|
89
84
|
})
|
|
90
85
|
.onConflictDoUpdate({
|
|
91
|
-
target: [
|
|
86
|
+
target: [t.key, t.tableName],
|
|
92
87
|
set: { bodyHash, status, response: responseText, expiresAt },
|
|
93
88
|
})
|
|
94
89
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import type { AnyRouter } from '@trpc/server'
|
|
3
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
4
3
|
import type { Hono as HonoType } from 'hono'
|
|
5
4
|
|
|
6
5
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
|
7
6
|
|
|
8
7
|
import type { StorageAdapter } from './storage/index'
|
|
9
8
|
import type { TableAccessInput } from './access'
|
|
9
|
+
import type { DbFor } from './db'
|
|
10
10
|
import type { StorageConfigInput } from './storage/buckets'
|
|
11
11
|
|
|
12
12
|
import { resolveAccessUser, validateAndResolveAccess } from './access'
|
|
13
13
|
import { createAuth, toAuthSessionResolver, withEmailAuthDefaults } from './auth'
|
|
14
14
|
import { resolveConfig, type BunderstackConfig } from './config'
|
|
15
15
|
import { resolveRealtimeRedisUrl } from './config'
|
|
16
|
+
import { detectDialect } from './dialect'
|
|
16
17
|
import { createEmail, emailProviderTag, type EmailFacade } from './email'
|
|
17
18
|
import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
|
|
18
19
|
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
@@ -20,7 +21,10 @@ import { buildCrudRouter } from './crud'
|
|
|
20
21
|
import { createDb } from './db'
|
|
21
22
|
import { buildHandler } from './handler'
|
|
22
23
|
import { withInternalTables } from './internal-tables'
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
PROVISION_INTERNALS,
|
|
26
|
+
type WithProvisionInternals,
|
|
27
|
+
} from './provision-internals'
|
|
24
28
|
import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
|
|
25
29
|
import { createRedisRealtimeBroker } from './realtime/redis'
|
|
26
30
|
import { deleteFileWithDerivatives } from './storage/delete'
|
|
@@ -70,7 +74,7 @@ export type BunderstackApp<
|
|
|
70
74
|
TRouter = undefined,
|
|
71
75
|
> = {
|
|
72
76
|
handler: (req: Request) => Promise<Response>
|
|
73
|
-
db:
|
|
77
|
+
db: DbFor<TSchema>
|
|
74
78
|
auth: AuthInstance
|
|
75
79
|
storage: StorageFacade
|
|
76
80
|
router: HonoType
|
|
@@ -80,8 +84,6 @@ export type BunderstackApp<
|
|
|
80
84
|
env: ValidatedEnv<TEnv>
|
|
81
85
|
/** Email facade; always present — send() throws when email isn't configured. */
|
|
82
86
|
email: EmailFacade
|
|
83
|
-
/** Push the merged schema (user + internal tables) to the database. */
|
|
84
|
-
provision: (options?: { force?: boolean }) => Promise<void>
|
|
85
87
|
/**
|
|
86
88
|
* Type-only carrier for client inference (`createClient<typeof app>()`).
|
|
87
89
|
* Never assigned at runtime.
|
|
@@ -110,7 +112,7 @@ export function createBunderstack<
|
|
|
110
112
|
/** Builder callback receiving the pre-wired `t` instance. */
|
|
111
113
|
trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
|
|
112
114
|
},
|
|
113
|
-
): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter
|
|
115
|
+
): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
|
|
114
116
|
export function createBunderstack<
|
|
115
117
|
TSchema extends Record<string, unknown>,
|
|
116
118
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -123,8 +125,8 @@ export function createBunderstack<
|
|
|
123
125
|
/** Prebuilt tRPC router (escape hatch for multi-file setups). */
|
|
124
126
|
trpc?: TRouter
|
|
125
127
|
},
|
|
126
|
-
): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter
|
|
127
|
-
export function createBunderstack<
|
|
128
|
+
): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
|
|
129
|
+
export async function createBunderstack<
|
|
128
130
|
TSchema extends Record<string, unknown>,
|
|
129
131
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
130
132
|
undefined,
|
|
@@ -136,17 +138,16 @@ export function createBunderstack<
|
|
|
136
138
|
| AnyRouter
|
|
137
139
|
| ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
|
|
138
140
|
},
|
|
139
|
-
):
|
|
140
|
-
TSchema,
|
|
141
|
-
TAccess,
|
|
142
|
-
BucketNamesOf<TStorage>,
|
|
143
|
-
TEnv,
|
|
144
|
-
AnyRouter | undefined
|
|
141
|
+
): Promise<
|
|
142
|
+
BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, AnyRouter | undefined>
|
|
145
143
|
> {
|
|
144
|
+
const dialect = detectDialect(options.schema)
|
|
146
145
|
// Env is validated FIRST: the app refuses to boot on missing/invalid vars,
|
|
147
146
|
// and everything downstream (config, email, trpc ctx) consumes the result.
|
|
148
147
|
const env = validateEnv(options.env, {
|
|
149
148
|
emailProvider: emailProviderTag(options.email),
|
|
149
|
+
defaultDatabaseUrl:
|
|
150
|
+
dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
|
|
150
151
|
})
|
|
151
152
|
const config = resolveConfig(options, env)
|
|
152
153
|
const email = createEmail(options.email, { env })
|
|
@@ -154,17 +155,21 @@ export function createBunderstack<
|
|
|
154
155
|
// schema used for the db client + provisioning. CRUD/access stay on the USER
|
|
155
156
|
// schema so internal tables never get a CRUD route.
|
|
156
157
|
const mergedSchema = withInternalTables(options.schema)
|
|
157
|
-
const db = createDb(mergedSchema,
|
|
158
|
+
const { db, driver } = await createDb(mergedSchema, {
|
|
159
|
+
...config.database,
|
|
160
|
+
dialect,
|
|
161
|
+
})
|
|
158
162
|
// `db` is typed with the merged schema (user tables + internal tables) so the
|
|
159
163
|
// storage/idempotency code can query the internal tables. The public surface
|
|
160
|
-
// and CRUD only expose the USER schema. TS can widen
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
const userDb = db as unknown as
|
|
164
|
+
// and CRUD only expose the USER schema. TS can widen the merged-schema db type
|
|
165
|
+
// on its own (storage/auth pass `db` directly), but it can't *narrow* a
|
|
166
|
+
// generic schema view, so this single intentional cast produces the
|
|
167
|
+
// user-facing, per-dialect db type. See `app.db` / crud below.
|
|
168
|
+
const userDb = db as unknown as DbFor<TSchema>
|
|
165
169
|
const auth = createAuth(
|
|
166
170
|
db,
|
|
167
171
|
withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
|
|
172
|
+
dialect,
|
|
168
173
|
)
|
|
169
174
|
// Internal routers consume the narrow AuthSessionResolver contract, not the
|
|
170
175
|
// raw better-auth instance. app.auth still exposes `auth` unchanged.
|
|
@@ -297,11 +302,18 @@ export function createBunderstack<
|
|
|
297
302
|
env,
|
|
298
303
|
email,
|
|
299
304
|
trpcRouter,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Hidden handle for the optional `bunderstack/provision` entry. Kept off the
|
|
308
|
+
// public type so provisioning stays opt-in (and drizzle-kit out of this
|
|
309
|
+
// module graph).
|
|
310
|
+
;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
|
|
311
|
+
db,
|
|
312
|
+
schema: mergedSchema,
|
|
313
|
+
databaseUrl: config.database.url,
|
|
314
|
+
migrationsFolder: config.database.migrations,
|
|
315
|
+
dialect,
|
|
316
|
+
driver,
|
|
305
317
|
}
|
|
306
318
|
|
|
307
319
|
return app
|
|
@@ -314,7 +326,6 @@ export type {
|
|
|
314
326
|
BunderstackConfig,
|
|
315
327
|
ResolvedConfig,
|
|
316
328
|
} from './config'
|
|
317
|
-
export { provisionSchema } from './provision'
|
|
318
329
|
export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
|
|
319
330
|
export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
|
|
320
331
|
export { createEmail } from './email'
|
|
@@ -353,16 +364,3 @@ export type {
|
|
|
353
364
|
} from './storage/buckets'
|
|
354
365
|
// StorageFacade is declared+exported inline above.
|
|
355
366
|
export type { TransformSpec } from './storage/thumbnails'
|
|
356
|
-
|
|
357
|
-
// Re-export drizzle builders so consumers share bunderstack's drizzle-orm instance
|
|
358
|
-
// and avoid type incompatibilities from duplicate installs.
|
|
359
|
-
export {
|
|
360
|
-
sqliteTable,
|
|
361
|
-
integer,
|
|
362
|
-
text,
|
|
363
|
-
real,
|
|
364
|
-
blob,
|
|
365
|
-
numeric,
|
|
366
|
-
foreignKey,
|
|
367
|
-
} from 'drizzle-orm/sqlite-core'
|
|
368
|
-
export { eq, and, or, not, gt, gte, lt, lte, desc, asc, sql } from 'drizzle-orm'
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/internal-tables-pg.ts — Postgres twins of the internal tables. Same
|
|
2
|
+
// table/column names and row shapes as the sqlite originals; timestamps stay
|
|
3
|
+
// integer milliseconds (bigint mode:number) so shared code never branches.
|
|
4
|
+
import {
|
|
5
|
+
bigint,
|
|
6
|
+
index,
|
|
7
|
+
integer,
|
|
8
|
+
pgTable,
|
|
9
|
+
primaryKey,
|
|
10
|
+
text,
|
|
11
|
+
} from 'drizzle-orm/pg-core'
|
|
12
|
+
|
|
13
|
+
export const bunderstackFilesPg = pgTable(
|
|
14
|
+
'bunderstack_file_meta',
|
|
15
|
+
{
|
|
16
|
+
fileId: text('file_id').primaryKey(),
|
|
17
|
+
bucket: text('bucket').notNull(),
|
|
18
|
+
ownerId: text('owner_id'),
|
|
19
|
+
scopeJson: text('scope_json'),
|
|
20
|
+
status: text('status').notNull(),
|
|
21
|
+
filename: text('filename'),
|
|
22
|
+
contentType: text('content_type'),
|
|
23
|
+
size: bigint('size', { mode: 'number' }),
|
|
24
|
+
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
|
25
|
+
confirmedAt: bigint('confirmed_at', { mode: 'number' }),
|
|
26
|
+
},
|
|
27
|
+
(t) => [
|
|
28
|
+
index('bfm_owner').on(t.ownerId),
|
|
29
|
+
index('bfm_scope').on(t.bucket, t.scopeJson),
|
|
30
|
+
index('bfm_sweep').on(t.status, t.createdAt),
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
export const bunderstackIdempotencyPg = pgTable(
|
|
35
|
+
'_bunderstack_idempotency',
|
|
36
|
+
{
|
|
37
|
+
key: text('key').notNull(),
|
|
38
|
+
tableName: text('table_name').notNull(),
|
|
39
|
+
bodyHash: text('body_hash').notNull(),
|
|
40
|
+
status: integer('status').notNull(),
|
|
41
|
+
response: text('response').notNull(),
|
|
42
|
+
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
|
43
|
+
},
|
|
44
|
+
(t) => [primaryKey({ columns: [t.key, t.tableName] })],
|
|
45
|
+
)
|
package/src/internal-tables.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getTableName, isTable } from 'drizzle-orm'
|
|
1
|
+
import { getTableName, is, isTable } from 'drizzle-orm'
|
|
2
|
+
import { PgDatabase } from 'drizzle-orm/pg-core'
|
|
2
3
|
import {
|
|
3
4
|
index,
|
|
4
5
|
integer,
|
|
@@ -7,6 +8,12 @@ import {
|
|
|
7
8
|
text,
|
|
8
9
|
} from 'drizzle-orm/sqlite-core'
|
|
9
10
|
|
|
11
|
+
import { detectDialect } from './dialect'
|
|
12
|
+
import {
|
|
13
|
+
bunderstackFilesPg,
|
|
14
|
+
bunderstackIdempotencyPg,
|
|
15
|
+
} from './internal-tables-pg'
|
|
16
|
+
|
|
10
17
|
export const bunderstackFiles = sqliteTable(
|
|
11
18
|
'bunderstack_file_meta',
|
|
12
19
|
{
|
|
@@ -51,11 +58,30 @@ export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
|
|
|
51
58
|
'_bunderstack_idempotency',
|
|
52
59
|
])
|
|
53
60
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
export const INTERNAL_TABLES_PG = {
|
|
62
|
+
bunderstackFiles: bunderstackFilesPg,
|
|
63
|
+
bunderstackIdempotency: bunderstackIdempotencyPg,
|
|
64
|
+
} as const
|
|
65
|
+
|
|
66
|
+
// Both dialect twins count as "ours" for the re-export identity check.
|
|
67
|
+
const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
|
|
68
|
+
[getTableName(bunderstackFiles), [bunderstackFiles, bunderstackFilesPg]],
|
|
69
|
+
[
|
|
70
|
+
getTableName(bunderstackIdempotency),
|
|
71
|
+
[bunderstackIdempotency, bunderstackIdempotencyPg],
|
|
72
|
+
],
|
|
57
73
|
])
|
|
58
74
|
|
|
75
|
+
/** Internal file-meta table matching the db's dialect. */
|
|
76
|
+
export function filesTableFor(db: unknown) {
|
|
77
|
+
return is(db, PgDatabase) ? bunderstackFilesPg : bunderstackFiles
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Internal idempotency table matching the db's dialect. */
|
|
81
|
+
export function idempotencyTableFor(db: unknown) {
|
|
82
|
+
return is(db, PgDatabase) ? bunderstackIdempotencyPg : bunderstackIdempotency
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
export function withInternalTables<TSchema extends Record<string, unknown>>(
|
|
60
86
|
schema: TSchema,
|
|
61
87
|
): TSchema & typeof INTERNAL_TABLES {
|
|
@@ -66,9 +92,9 @@ export function withInternalTables<TSchema extends Record<string, unknown>>(
|
|
|
66
92
|
const name = getTableName(value)
|
|
67
93
|
if (!INTERNAL_TABLE_NAMES.has(name)) continue
|
|
68
94
|
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
// Re-exported from bunderstack/schema — already in user schema.
|
|
95
|
+
const candidates = INTERNAL_TABLE_CANDIDATES.get(name)
|
|
96
|
+
if (candidates?.includes(value)) {
|
|
97
|
+
// Re-exported from bunderstack/schema(-pg) — already in user schema.
|
|
72
98
|
continue
|
|
73
99
|
}
|
|
74
100
|
|
|
@@ -77,7 +103,9 @@ export function withInternalTables<TSchema extends Record<string, unknown>>(
|
|
|
77
103
|
)
|
|
78
104
|
}
|
|
79
105
|
|
|
80
|
-
|
|
106
|
+
const internal =
|
|
107
|
+
detectDialect(schema) === 'pg' ? INTERNAL_TABLES_PG : INTERNAL_TABLES
|
|
108
|
+
for (const [key, table] of Object.entries(internal)) {
|
|
81
109
|
if (!(key in merged)) {
|
|
82
110
|
;(merged as Record<string, unknown>)[key] = table
|
|
83
111
|
}
|
package/src/list-query.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
and,
|
|
5
3
|
asc,
|
|
@@ -7,14 +5,18 @@ import {
|
|
|
7
5
|
eq,
|
|
8
6
|
getTableColumns,
|
|
9
7
|
gt,
|
|
8
|
+
ilike,
|
|
10
9
|
inArray,
|
|
10
|
+
is,
|
|
11
11
|
like,
|
|
12
12
|
lt,
|
|
13
13
|
or,
|
|
14
14
|
sql,
|
|
15
15
|
type SQL,
|
|
16
16
|
} from 'drizzle-orm'
|
|
17
|
+
import { PgTable } from 'drizzle-orm/pg-core'
|
|
17
18
|
|
|
19
|
+
import type { AnyDb } from './dialect'
|
|
18
20
|
import type { ResolvedTableAccess, SortOrder } from './access'
|
|
19
21
|
|
|
20
22
|
import { ErrorCode, ListQueryError } from './errors'
|
|
@@ -180,9 +182,12 @@ function buildSearchWhere(
|
|
|
180
182
|
if (!q || !searchableColumns?.length) return undefined
|
|
181
183
|
const columns = getTableColumns(table)
|
|
182
184
|
const pattern = `%${q.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`
|
|
185
|
+
// LIKE is case-insensitive in SQLite but case-sensitive in Postgres; use
|
|
186
|
+
// ilike there so search behaves identically across dialects.
|
|
187
|
+
const likeOp = is(table, PgTable) ? ilike : like
|
|
183
188
|
const conditions = searchableColumns
|
|
184
189
|
.filter((name) => name in columns)
|
|
185
|
-
.map((name) =>
|
|
190
|
+
.map((name) => likeOp(columns[name]!, pattern))
|
|
186
191
|
return conditions.length ? or(...conditions) : undefined
|
|
187
192
|
}
|
|
188
193
|
|
|
@@ -325,7 +330,7 @@ function buildOrderBy(
|
|
|
325
330
|
}
|
|
326
331
|
|
|
327
332
|
export async function executeList<T extends Record<string, unknown>>(
|
|
328
|
-
db:
|
|
333
|
+
db: AnyDb,
|
|
329
334
|
table: Parameters<typeof getTableColumns>[0],
|
|
330
335
|
access: ResolvedTableAccess,
|
|
331
336
|
params: ParsedListParams,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/provision-internals.ts
|
|
2
|
+
import type { AnyDb, Dialect } from './dialect'
|
|
3
|
+
import type { Driver } from './db'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hidden handle connecting `createBunderstack()` to the optional
|
|
7
|
+
* `bunderstack/provision` entry. Lives in its own module so the main entry
|
|
8
|
+
* never imports provision code (and its drizzle-kit reference).
|
|
9
|
+
*/
|
|
10
|
+
export const PROVISION_INTERNALS: unique symbol = Symbol.for(
|
|
11
|
+
'bunderstack.provision-internals',
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
export interface ProvisionInternals {
|
|
15
|
+
/** Runtime db typed over the MERGED schema (user + internal tables). */
|
|
16
|
+
db: AnyDb
|
|
17
|
+
/** Merged schema used for push. */
|
|
18
|
+
schema: Record<string, unknown>
|
|
19
|
+
databaseUrl: string
|
|
20
|
+
/** Resolved migrations folder (config `database.migrations`). */
|
|
21
|
+
migrationsFolder: string
|
|
22
|
+
dialect: Dialect
|
|
23
|
+
driver: Driver
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WithProvisionInternals {
|
|
27
|
+
[PROVISION_INTERNALS]?: ProvisionInternals
|
|
28
|
+
}
|
package/src/provision.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
// src/provision.ts
|
|
2
|
+
import { access, mkdir } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
2
4
|
|
|
3
|
-
import {
|
|
4
|
-
import { dirname } from 'node:path'
|
|
5
|
+
import type { AnyDb, Dialect } from './dialect'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
import { detectDialect } from './dialect'
|
|
8
|
+
import {
|
|
9
|
+
PROVISION_INTERNALS,
|
|
10
|
+
type WithProvisionInternals,
|
|
11
|
+
} from './provision-internals'
|
|
12
|
+
|
|
13
|
+
/** Create the local backing directory for file-based urls, per dialect. */
|
|
14
|
+
async function ensureLocalDataDir(url: string, dialect: Dialect): Promise<void> {
|
|
15
|
+
if (dialect === 'pg') {
|
|
16
|
+
// PGlite data dir: `file:<dir>` or a bare path; server/memory urls need nothing.
|
|
17
|
+
if (/^postgres(ql)?:\/\//.test(url)) return
|
|
18
|
+
const raw = url.startsWith('file:') ? url.slice('file:'.length) : url
|
|
19
|
+
if (raw === ':memory:' || raw.startsWith('memory://')) return
|
|
20
|
+
await mkdir(raw, { recursive: true })
|
|
21
|
+
return
|
|
22
|
+
}
|
|
7
23
|
const match = /^file:(.+)$/.exec(url)
|
|
8
24
|
if (!match) return
|
|
9
25
|
const filePath = match[1]!
|
|
@@ -11,22 +27,50 @@ async function ensureSqliteFileParent(url: string): Promise<void> {
|
|
|
11
27
|
await mkdir(dirname(filePath), { recursive: true })
|
|
12
28
|
}
|
|
13
29
|
|
|
30
|
+
async function exists(path: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
await access(path)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DRIZZLE_KIT_HINT =
|
|
40
|
+
'[bunderstack] Schema push requires drizzle-kit, which is not installed.\n' +
|
|
41
|
+
' Development: run `bun add -d drizzle-kit` — provision() will push schema changes to the database on startup.\n' +
|
|
42
|
+
' Production: generate migrations locally with `bunx drizzle-kit generate` and commit the folder — provision() applies them without drizzle-kit.'
|
|
43
|
+
|
|
14
44
|
/** Push the merged schema to the database via drizzle-kit/api. */
|
|
15
45
|
export async function provisionSchema<TSchema extends Record<string, unknown>>(
|
|
16
|
-
db:
|
|
46
|
+
db: AnyDb,
|
|
17
47
|
schema: TSchema,
|
|
18
48
|
options?: { force?: boolean; databaseUrl?: string },
|
|
19
49
|
): Promise<void> {
|
|
50
|
+
const dialect = detectDialect(schema)
|
|
20
51
|
if (options?.databaseUrl) {
|
|
21
|
-
await
|
|
52
|
+
await ensureLocalDataDir(options.databaseUrl, dialect)
|
|
22
53
|
}
|
|
23
54
|
|
|
24
|
-
|
|
25
|
-
|
|
55
|
+
let kit: typeof import('drizzle-kit/api')
|
|
56
|
+
try {
|
|
57
|
+
// Ignore comments keep bundlers (vite/nitro, webpack) from resolving
|
|
58
|
+
// drizzle-kit at build time — this branch only runs in development.
|
|
59
|
+
kit = await import(
|
|
60
|
+
/* @vite-ignore */ /* webpackIgnore: true */ 'drizzle-kit/api'
|
|
61
|
+
)
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
throw new Error(DRIZZLE_KIT_HINT, { cause })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result =
|
|
67
|
+
dialect === 'pg'
|
|
68
|
+
? await kit.pushSchema(schema, db as never)
|
|
69
|
+
: await kit.pushSQLiteSchema(schema, db as never)
|
|
26
70
|
|
|
27
71
|
if (result.hasDataLoss && !options?.force) {
|
|
28
72
|
throw new Error(
|
|
29
|
-
'[bunderstack] Schema push would cause data loss. Run `bunx drizzle-kit push` or call
|
|
73
|
+
'[bunderstack] Schema push would cause data loss. Run `bunx drizzle-kit push` or call provision(app, { force: true }).',
|
|
30
74
|
)
|
|
31
75
|
}
|
|
32
76
|
|
|
@@ -44,3 +88,50 @@ export async function provisionSchema<TSchema extends Record<string, unknown>>(
|
|
|
44
88
|
`[bunderstack] provisioned ${result.statementsToExecute.length} schema change(s)`,
|
|
45
89
|
)
|
|
46
90
|
}
|
|
91
|
+
|
|
92
|
+
const MIGRATOR_MODULES = {
|
|
93
|
+
libsql: 'drizzle-orm/libsql/migrator',
|
|
94
|
+
pglite: 'drizzle-orm/pglite/migrator',
|
|
95
|
+
'bun-sql': 'drizzle-orm/bun-sql/migrator',
|
|
96
|
+
'postgres-js': 'drizzle-orm/postgres-js/migrator',
|
|
97
|
+
} as const
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Provision the database for a Bunderstack app.
|
|
101
|
+
*
|
|
102
|
+
* The migrations folder is the mode switch:
|
|
103
|
+
* - `<migrations>/meta/_journal.json` exists → apply committed migrations via
|
|
104
|
+
* drizzle-orm's migrator. Pure runtime; drizzle-kit is never imported, so a
|
|
105
|
+
* fresh clone deploys with `bun install --production`.
|
|
106
|
+
* - No migrations → development: push the schema straight to the database via
|
|
107
|
+
* drizzle-kit (install it with `bun add -d drizzle-kit`).
|
|
108
|
+
*
|
|
109
|
+
* Once you run `bunx drizzle-kit generate` for the first time, provision stops
|
|
110
|
+
* pushing and every schema change goes through an explicit `generate`.
|
|
111
|
+
*/
|
|
112
|
+
export async function provision(
|
|
113
|
+
app: object,
|
|
114
|
+
options?: { force?: boolean },
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const internals = (app as WithProvisionInternals)[PROVISION_INTERNALS]
|
|
117
|
+
if (!internals) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'[bunderstack] provision() expects the app returned by createBunderstack().',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { db, schema, databaseUrl, migrationsFolder, dialect, driver } =
|
|
124
|
+
internals
|
|
125
|
+
const journal = join(migrationsFolder, 'meta', '_journal.json')
|
|
126
|
+
|
|
127
|
+
if (await exists(journal)) {
|
|
128
|
+
await ensureLocalDataDir(databaseUrl, dialect)
|
|
129
|
+
const { migrate } = (await import(
|
|
130
|
+
/* @vite-ignore */ /* webpackIgnore: true */ MIGRATOR_MODULES[driver]
|
|
131
|
+
)) as { migrate: (db: never, cfg: { migrationsFolder: string }) => Promise<void> }
|
|
132
|
+
await migrate(db as never, { migrationsFolder })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await provisionSchema(db, schema, { force: options?.force, databaseUrl })
|
|
137
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// src/schema-export-pg.ts — pg twins under the same names bunderstack/schema
|
|
2
|
+
// uses, so `export * from 'bunderstack/schema/pg'` mirrors the sqlite setup.
|
|
3
|
+
export {
|
|
4
|
+
bunderstackFilesPg as bunderstackFiles,
|
|
5
|
+
bunderstackIdempotencyPg as bunderstackIdempotency,
|
|
6
|
+
} from './internal-tables-pg'
|
package/src/storage/delete.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// src/storage/delete.ts
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type { AnyDb } from '../dialect'
|
|
4
3
|
import type { StorageAdapter } from './index'
|
|
5
4
|
|
|
6
5
|
import { deleteFileMetaRow } from './file-meta'
|
|
@@ -14,7 +13,7 @@ import { deleteFileMetaRow } from './file-meta'
|
|
|
14
13
|
*/
|
|
15
14
|
export async function deleteFileWithDerivatives(
|
|
16
15
|
adapter: StorageAdapter,
|
|
17
|
-
db:
|
|
16
|
+
db: AnyDb,
|
|
18
17
|
fileId: string,
|
|
19
18
|
): Promise<void> {
|
|
20
19
|
if (adapter.list) {
|
package/src/storage/file-meta.ts
CHANGED
|
@@ -27,21 +27,20 @@
|
|
|
27
27
|
* See docs/plans/2026-06-28-multi-bucket-storage-design.md §6 for full rationale.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
31
|
-
|
|
32
30
|
import { eq, and, lt, sql } from 'drizzle-orm'
|
|
33
31
|
|
|
32
|
+
import type { AnyDb } from '../dialect'
|
|
34
33
|
import type { ScopeMap } from '../access'
|
|
35
34
|
|
|
36
35
|
import { rowMatchesScope } from '../access'
|
|
37
|
-
import { bunderstackFiles } from '../internal-tables'
|
|
36
|
+
import { bunderstackFiles, filesTableFor } from '../internal-tables'
|
|
38
37
|
|
|
39
38
|
export type FileMetaRow = typeof bunderstackFiles.$inferSelect
|
|
40
39
|
|
|
41
40
|
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
|
42
41
|
|
|
43
42
|
export async function insertPendingFile(
|
|
44
|
-
db:
|
|
43
|
+
db: AnyDb,
|
|
45
44
|
input: {
|
|
46
45
|
fileId: string
|
|
47
46
|
bucket: string
|
|
@@ -51,7 +50,8 @@ export async function insertPendingFile(
|
|
|
51
50
|
contentType: string | null
|
|
52
51
|
},
|
|
53
52
|
): Promise<void> {
|
|
54
|
-
|
|
53
|
+
const files = filesTableFor(db)
|
|
54
|
+
await db.insert(files).values({
|
|
55
55
|
fileId: input.fileId,
|
|
56
56
|
bucket: input.bucket,
|
|
57
57
|
ownerId: input.ownerId,
|
|
@@ -66,7 +66,7 @@ export async function insertPendingFile(
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export async function insertReadyFile(
|
|
69
|
-
db:
|
|
69
|
+
db: AnyDb,
|
|
70
70
|
input: {
|
|
71
71
|
fileId: string
|
|
72
72
|
bucket: string
|
|
@@ -77,8 +77,9 @@ export async function insertReadyFile(
|
|
|
77
77
|
size: number | null
|
|
78
78
|
},
|
|
79
79
|
): Promise<void> {
|
|
80
|
+
const files = filesTableFor(db)
|
|
80
81
|
const now = Date.now()
|
|
81
|
-
await db.insert(
|
|
82
|
+
await db.insert(files).values({
|
|
82
83
|
fileId: input.fileId,
|
|
83
84
|
bucket: input.bucket,
|
|
84
85
|
ownerId: input.ownerId,
|
|
@@ -93,79 +94,76 @@ export async function insertReadyFile(
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export async function markFileReady(
|
|
96
|
-
db:
|
|
97
|
+
db: AnyDb,
|
|
97
98
|
fileId: string,
|
|
98
99
|
patch: { size: number | null; contentType: string | null },
|
|
99
100
|
): Promise<void> {
|
|
101
|
+
const files = filesTableFor(db)
|
|
100
102
|
await db
|
|
101
|
-
.update(
|
|
103
|
+
.update(files)
|
|
102
104
|
.set({
|
|
103
105
|
status: 'ready',
|
|
104
106
|
confirmedAt: Date.now(),
|
|
105
107
|
size: patch.size,
|
|
106
108
|
contentType: patch.contentType,
|
|
107
109
|
})
|
|
108
|
-
.where(eq(
|
|
110
|
+
.where(eq(files.fileId, fileId))
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
export async function getFileMeta(
|
|
112
|
-
db:
|
|
114
|
+
db: AnyDb,
|
|
113
115
|
fileId: string,
|
|
114
116
|
): Promise<FileMetaRow | null> {
|
|
117
|
+
const files = filesTableFor(db)
|
|
115
118
|
const rows = await db
|
|
116
119
|
.select()
|
|
117
|
-
.from(
|
|
118
|
-
.where(eq(
|
|
120
|
+
.from(files)
|
|
121
|
+
.where(eq(files.fileId, fileId))
|
|
119
122
|
.limit(1)
|
|
120
|
-
return rows[0] ?? null
|
|
123
|
+
return (rows[0] as FileMetaRow | undefined) ?? null
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
export async function deleteFileMetaRow(
|
|
124
|
-
db:
|
|
127
|
+
db: AnyDb,
|
|
125
128
|
fileId: string,
|
|
126
129
|
): Promise<void> {
|
|
127
|
-
|
|
130
|
+
const files = filesTableFor(db)
|
|
131
|
+
await db.delete(files).where(eq(files.fileId, fileId))
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
// ─── Sweep ────────────────────────────────────────────────────────────────────
|
|
131
135
|
|
|
132
136
|
export async function listStalePendingFiles(
|
|
133
|
-
db:
|
|
137
|
+
db: AnyDb,
|
|
134
138
|
olderThanMs: number,
|
|
135
139
|
): Promise<FileMetaRow[]> {
|
|
140
|
+
const files = filesTableFor(db)
|
|
136
141
|
return db
|
|
137
142
|
.select()
|
|
138
|
-
.from(
|
|
139
|
-
.where(
|
|
140
|
-
and(
|
|
141
|
-
eq(bunderstackFiles.status, 'pending'),
|
|
142
|
-
lt(bunderstackFiles.createdAt, olderThanMs),
|
|
143
|
-
),
|
|
144
|
-
)
|
|
143
|
+
.from(files)
|
|
144
|
+
.where(and(eq(files.status, 'pending'), lt(files.createdAt, olderThanMs)))
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
// ─── Quota ────────────────────────────────────────────────────────────────────
|
|
148
148
|
|
|
149
149
|
export async function sumReadySize(
|
|
150
|
-
db:
|
|
150
|
+
db: AnyDb,
|
|
151
151
|
q: { bucket: string; ownerId?: string; scopeJson?: string },
|
|
152
152
|
): Promise<number> {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
eq(bunderstackFiles.bucket, q.bucket),
|
|
156
|
-
]
|
|
153
|
+
const files = filesTableFor(db)
|
|
154
|
+
const conditions = [eq(files.status, 'ready'), eq(files.bucket, q.bucket)]
|
|
157
155
|
if (q.ownerId !== undefined) {
|
|
158
|
-
conditions.push(eq(
|
|
156
|
+
conditions.push(eq(files.ownerId, q.ownerId))
|
|
159
157
|
}
|
|
160
158
|
if (q.scopeJson !== undefined) {
|
|
161
|
-
conditions.push(eq(
|
|
159
|
+
conditions.push(eq(files.scopeJson, q.scopeJson))
|
|
162
160
|
}
|
|
163
161
|
|
|
164
162
|
const rows = await db
|
|
165
163
|
.select({
|
|
166
|
-
total: sql<number>`coalesce(sum(${
|
|
164
|
+
total: sql<number>`coalesce(sum(${files.size}), 0)`,
|
|
167
165
|
})
|
|
168
|
-
.from(
|
|
166
|
+
.from(files)
|
|
169
167
|
.where(and(...conditions))
|
|
170
168
|
|
|
171
169
|
const raw = rows[0]?.total ?? 0
|
package/src/storage/router.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
1
|
// src/storage/router.ts
|
|
3
2
|
import type { Context } from 'hono'
|
|
4
3
|
|
|
@@ -11,6 +10,7 @@ import type {
|
|
|
11
10
|
AuthSessionResolver,
|
|
12
11
|
OperationRule,
|
|
13
12
|
} from '../access'
|
|
13
|
+
import type { AnyDb } from '../dialect'
|
|
14
14
|
import type { BucketStorageRegistry } from './registry'
|
|
15
15
|
|
|
16
16
|
import { checkAccess, resolveSession } from '../access'
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export interface BucketStorageRouterOptions {
|
|
37
37
|
registry: BucketStorageRegistry
|
|
38
|
-
db:
|
|
38
|
+
db: AnyDb
|
|
39
39
|
auth: AuthSessionResolver | undefined
|
|
40
40
|
/** Default presign TTL (seconds) for PUT/GET URLs. */
|
|
41
41
|
presignExpiresSec?: number
|
|
@@ -511,7 +511,7 @@ export function buildBucketStorageRouter(
|
|
|
511
511
|
* dimension (perUser uses ownerId; perScope uses scopeJson).
|
|
512
512
|
*/
|
|
513
513
|
async function quotaExceeded(
|
|
514
|
-
db:
|
|
514
|
+
db: AnyDb,
|
|
515
515
|
bucket: string,
|
|
516
516
|
quota: { perUserBytes?: number; perScopeBytes?: number },
|
|
517
517
|
ownerId: string | undefined,
|
package/src/storage/sweep.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// src/storage/sweep.ts
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type { AnyDb } from '../dialect'
|
|
4
3
|
import type { BucketStorageRegistry } from './registry'
|
|
5
4
|
|
|
6
5
|
import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
|
|
@@ -12,7 +11,7 @@ import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
|
|
|
12
11
|
*/
|
|
13
12
|
export async function sweepOrphans(
|
|
14
13
|
registry: BucketStorageRegistry,
|
|
15
|
-
db:
|
|
14
|
+
db: AnyDb,
|
|
16
15
|
olderThanMs: number,
|
|
17
16
|
): Promise<number> {
|
|
18
17
|
const cutoff = Date.now() - olderThanMs
|
package/src/trpc.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// src/trpc.ts — pre-wired tRPC instance for bunderstack endpoints.
|
|
2
|
-
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
3
|
-
|
|
4
2
|
import { initTRPC, TRPCError } from '@trpc/server'
|
|
5
3
|
import superjson from 'superjson'
|
|
6
4
|
|
|
7
5
|
import type { AccessUser } from './access'
|
|
6
|
+
import type { DbFor } from './db'
|
|
8
7
|
import type { EmailFacade } from './email'
|
|
9
8
|
|
|
10
9
|
export type TRPCContext<
|
|
11
10
|
TSchema extends Record<string, unknown>,
|
|
12
11
|
TEnvResult = Record<string, unknown>,
|
|
13
12
|
> = {
|
|
14
|
-
db:
|
|
13
|
+
db: DbFor<TSchema>
|
|
15
14
|
user: AccessUser | null
|
|
16
15
|
env: TEnvResult
|
|
17
16
|
email: EmailFacade
|
package/src/typeid-pg.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/typeid-pg.ts — Postgres twin of the typeid column builder. The codec
|
|
2
|
+
// (generate/parse/encode/decode) is dialect-neutral and lives in ./typeid;
|
|
3
|
+
// only the drizzle customType wrapper differs.
|
|
4
|
+
import { customType } from 'drizzle-orm/pg-core'
|
|
5
|
+
|
|
6
|
+
import { isValidPrefix, type TypeId } from './typeid'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Drizzle column builder for a branded TypeID text value (Postgres). Stores a
|
|
10
|
+
* plain `text` column so drizzle-kit migrations and `$inferSelect` work
|
|
11
|
+
* unchanged.
|
|
12
|
+
*
|
|
13
|
+
* id: typeid('post').primaryKey().$defaultFn(() => generate('post'))
|
|
14
|
+
*/
|
|
15
|
+
export function typeid<P extends string>(prefix: P) {
|
|
16
|
+
if (!isValidPrefix(prefix))
|
|
17
|
+
throw new Error(`Invalid typeid prefix: "${prefix}"`)
|
|
18
|
+
return customType<{ data: TypeId<P>; driverData: string }>({
|
|
19
|
+
dataType: () => 'text',
|
|
20
|
+
})()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { generate, parse, asTypeId, encode, decode } from './typeid'
|
|
24
|
+
export type { TypeId } from './typeid'
|