bunderstack 0.1.0 → 0.3.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/README.md +33 -0
- package/package.json +23 -3
- package/src/auth.ts +5 -7
- package/src/config.ts +22 -5
- package/src/crud.ts +2 -3
- package/src/db.ts +100 -7
- package/src/dialect.ts +38 -0
- package/src/env.ts +8 -2
- package/src/idempotency.ts +16 -21
- package/src/index.ts +62 -42
- package/src/internal-tables-pg.ts +45 -0
- package/src/internal-tables.ts +36 -8
- package/src/list-query.ts +9 -4
- package/src/manifest.ts +52 -0
- package/src/provision-internals.ts +28 -0
- package/src/provision.ts +100 -9
- package/src/schema-export-pg.ts +6 -0
- package/src/storage/buckets.ts +30 -1
- 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/src/index.ts
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
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'
|
|
19
|
+
import { buildManifest, type BunderstackManifest } from './manifest'
|
|
18
20
|
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
19
21
|
import { buildCrudRouter } from './crud'
|
|
20
22
|
import { createDb } from './db'
|
|
21
23
|
import { buildHandler } from './handler'
|
|
22
24
|
import { withInternalTables } from './internal-tables'
|
|
23
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
PROVISION_INTERNALS,
|
|
27
|
+
type WithProvisionInternals,
|
|
28
|
+
} from './provision-internals'
|
|
24
29
|
import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
|
|
25
30
|
import { createRedisRealtimeBroker } from './realtime/redis'
|
|
26
31
|
import { deleteFileWithDerivatives } from './storage/delete'
|
|
@@ -70,7 +75,7 @@ export type BunderstackApp<
|
|
|
70
75
|
TRouter = undefined,
|
|
71
76
|
> = {
|
|
72
77
|
handler: (req: Request) => Promise<Response>
|
|
73
|
-
db:
|
|
78
|
+
db: DbFor<TSchema>
|
|
74
79
|
auth: AuthInstance
|
|
75
80
|
storage: StorageFacade
|
|
76
81
|
router: HonoType
|
|
@@ -80,8 +85,8 @@ export type BunderstackApp<
|
|
|
80
85
|
env: ValidatedEnv<TEnv>
|
|
81
86
|
/** Email facade; always present — send() throws when email isn't configured. */
|
|
82
87
|
email: EmailFacade
|
|
83
|
-
/**
|
|
84
|
-
|
|
88
|
+
/** Deploy-time introspection: what this app needs provisioned. */
|
|
89
|
+
manifest: BunderstackManifest
|
|
85
90
|
/**
|
|
86
91
|
* Type-only carrier for client inference (`createClient<typeof app>()`).
|
|
87
92
|
* Never assigned at runtime.
|
|
@@ -110,7 +115,7 @@ export function createBunderstack<
|
|
|
110
115
|
/** Builder callback receiving the pre-wired `t` instance. */
|
|
111
116
|
trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
|
|
112
117
|
},
|
|
113
|
-
): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter
|
|
118
|
+
): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
|
|
114
119
|
export function createBunderstack<
|
|
115
120
|
TSchema extends Record<string, unknown>,
|
|
116
121
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -123,8 +128,8 @@ export function createBunderstack<
|
|
|
123
128
|
/** Prebuilt tRPC router (escape hatch for multi-file setups). */
|
|
124
129
|
trpc?: TRouter
|
|
125
130
|
},
|
|
126
|
-
): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter
|
|
127
|
-
export function createBunderstack<
|
|
131
|
+
): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
|
|
132
|
+
export async function createBunderstack<
|
|
128
133
|
TSchema extends Record<string, unknown>,
|
|
129
134
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
130
135
|
undefined,
|
|
@@ -136,35 +141,47 @@ export function createBunderstack<
|
|
|
136
141
|
| AnyRouter
|
|
137
142
|
| ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
|
|
138
143
|
},
|
|
139
|
-
):
|
|
140
|
-
TSchema,
|
|
141
|
-
TAccess,
|
|
142
|
-
BucketNamesOf<TStorage>,
|
|
143
|
-
TEnv,
|
|
144
|
-
AnyRouter | undefined
|
|
144
|
+
): Promise<
|
|
145
|
+
BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, AnyRouter | undefined>
|
|
145
146
|
> {
|
|
147
|
+
const dialect = detectDialect(options.schema)
|
|
146
148
|
// Env is validated FIRST: the app refuses to boot on missing/invalid vars,
|
|
147
149
|
// and everything downstream (config, email, trpc ctx) consumes the result.
|
|
148
150
|
const env = validateEnv(options.env, {
|
|
149
151
|
emailProvider: emailProviderTag(options.email),
|
|
152
|
+
defaultDatabaseUrl:
|
|
153
|
+
dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
|
|
150
154
|
})
|
|
151
155
|
const config = resolveConfig(options, env)
|
|
156
|
+
// Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
|
|
157
|
+
// the app declaration only to read `app.manifest`. The boot must never touch
|
|
158
|
+
// the outside world — force an in-memory db (':memory:' is valid for both
|
|
159
|
+
// dialects) and skip Redis below. Env validation is already lenient (env.ts).
|
|
160
|
+
const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
|
|
161
|
+
if (introspect) {
|
|
162
|
+
config.database.url = ':memory:'
|
|
163
|
+
config.database.authToken = undefined
|
|
164
|
+
}
|
|
152
165
|
const email = createEmail(options.email, { env })
|
|
153
166
|
// Merge bunderstack's internal tables (file-meta, idempotency) into the
|
|
154
167
|
// schema used for the db client + provisioning. CRUD/access stay on the USER
|
|
155
168
|
// schema so internal tables never get a CRUD route.
|
|
156
169
|
const mergedSchema = withInternalTables(options.schema)
|
|
157
|
-
const db = createDb(mergedSchema,
|
|
170
|
+
const { db, driver } = await createDb(mergedSchema, {
|
|
171
|
+
...config.database,
|
|
172
|
+
dialect,
|
|
173
|
+
})
|
|
158
174
|
// `db` is typed with the merged schema (user tables + internal tables) so the
|
|
159
175
|
// 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
|
|
176
|
+
// and CRUD only expose the USER schema. TS can widen the merged-schema db type
|
|
177
|
+
// on its own (storage/auth pass `db` directly), but it can't *narrow* a
|
|
178
|
+
// generic schema view, so this single intentional cast produces the
|
|
179
|
+
// user-facing, per-dialect db type. See `app.db` / crud below.
|
|
180
|
+
const userDb = db as unknown as DbFor<TSchema>
|
|
165
181
|
const auth = createAuth(
|
|
166
182
|
db,
|
|
167
183
|
withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
|
|
184
|
+
dialect,
|
|
168
185
|
)
|
|
169
186
|
// Internal routers consume the narrow AuthSessionResolver contract, not the
|
|
170
187
|
// raw better-auth instance. app.auth still exposes `auth` unchanged.
|
|
@@ -175,9 +192,10 @@ export function createBunderstack<
|
|
|
175
192
|
)
|
|
176
193
|
const realtimeBufferSize =
|
|
177
194
|
typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
|
|
178
|
-
const redisUrl =
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
const redisUrl =
|
|
196
|
+
config.realtime && !introspect
|
|
197
|
+
? resolveRealtimeRedisUrl(config.realtime, env)
|
|
198
|
+
: undefined
|
|
181
199
|
const broker = config.realtime
|
|
182
200
|
? redisUrl
|
|
183
201
|
? createRedisRealtimeBroker({
|
|
@@ -297,11 +315,25 @@ export function createBunderstack<
|
|
|
297
315
|
env,
|
|
298
316
|
email,
|
|
299
317
|
trpcRouter,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
318
|
+
manifest: buildManifest({
|
|
319
|
+
schema: options.schema,
|
|
320
|
+
dialect,
|
|
321
|
+
storage: config.storage,
|
|
322
|
+
envConfig: options.env as EnvConfigInput | undefined,
|
|
323
|
+
realtime: Boolean(config.realtime),
|
|
324
|
+
}),
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Hidden handle for the optional `bunderstack/provision` entry. Kept off the
|
|
328
|
+
// public type so provisioning stays opt-in (and drizzle-kit out of this
|
|
329
|
+
// module graph).
|
|
330
|
+
;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
|
|
331
|
+
db,
|
|
332
|
+
schema: mergedSchema,
|
|
333
|
+
databaseUrl: config.database.url,
|
|
334
|
+
migrationsFolder: config.database.migrations,
|
|
335
|
+
dialect,
|
|
336
|
+
driver,
|
|
305
337
|
}
|
|
306
338
|
|
|
307
339
|
return app
|
|
@@ -314,9 +346,10 @@ export type {
|
|
|
314
346
|
BunderstackConfig,
|
|
315
347
|
ResolvedConfig,
|
|
316
348
|
} from './config'
|
|
317
|
-
export { provisionSchema } from './provision'
|
|
318
349
|
export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
|
|
319
350
|
export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
|
|
351
|
+
export { buildManifest } from './manifest'
|
|
352
|
+
export type { BunderstackManifest, ManifestEnvVar } from './manifest'
|
|
320
353
|
export { createEmail } from './email'
|
|
321
354
|
export type {
|
|
322
355
|
EmailMessage,
|
|
@@ -353,16 +386,3 @@ export type {
|
|
|
353
386
|
} from './storage/buckets'
|
|
354
387
|
// StorageFacade is declared+exported inline above.
|
|
355
388
|
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,
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/manifest.ts — deploy-time introspection surface. Pure: consumes already
|
|
2
|
+
// resolved config pieces, never reads process.env or touches the network.
|
|
3
|
+
// Deployment platforms (Bunderhost) import the app declaration with
|
|
4
|
+
// BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
|
|
5
|
+
import type { ZodType } from 'zod'
|
|
6
|
+
|
|
7
|
+
import type { Dialect } from './dialect'
|
|
8
|
+
import type { EnvConfigInput } from './env'
|
|
9
|
+
import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
|
|
10
|
+
|
|
11
|
+
export type ManifestEnvVar = { key: string; required: boolean }
|
|
12
|
+
|
|
13
|
+
export type BunderstackManifest = {
|
|
14
|
+
dialect: Dialect
|
|
15
|
+
tables: string[]
|
|
16
|
+
defaultBucket: string
|
|
17
|
+
buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
|
|
18
|
+
realtime: boolean
|
|
19
|
+
env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describeSection(
|
|
23
|
+
section: Record<string, ZodType> | undefined,
|
|
24
|
+
): ManifestEnvVar[] {
|
|
25
|
+
return Object.entries(section ?? {}).map(([key, schema]) => ({
|
|
26
|
+
key,
|
|
27
|
+
required: !schema.safeParse(undefined).success,
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildManifest(args: {
|
|
32
|
+
schema: Record<string, unknown>
|
|
33
|
+
dialect: Dialect
|
|
34
|
+
storage: ResolvedStorageBuckets
|
|
35
|
+
envConfig: EnvConfigInput | undefined
|
|
36
|
+
realtime: boolean
|
|
37
|
+
}): BunderstackManifest {
|
|
38
|
+
return {
|
|
39
|
+
dialect: args.dialect,
|
|
40
|
+
tables: Object.keys(args.schema),
|
|
41
|
+
defaultBucket: args.storage.defaultBucket,
|
|
42
|
+
buckets: [...args.storage.buckets.values()].map((bucket) => ({
|
|
43
|
+
name: bucket.name,
|
|
44
|
+
visibility: bucket.visibility,
|
|
45
|
+
})),
|
|
46
|
+
realtime: args.realtime,
|
|
47
|
+
env: {
|
|
48
|
+
server: describeSection(args.envConfig?.server),
|
|
49
|
+
client: describeSection(args.envConfig?.client),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -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/buckets.ts
CHANGED
|
@@ -108,6 +108,32 @@ export function parseSize(value: string | number): number {
|
|
|
108
108
|
return Math.floor(num * multiplier)
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Platform override (Bunderhost & co.)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A deployment platform that injects BUNDERSTACK_S3_ENDPOINT forces every
|
|
117
|
+
* bucket onto that backend — code-level `local`/per-bucket `s3` blocks are
|
|
118
|
+
* ignored so apps deploy unchanged. Logical buckets already prefix object
|
|
119
|
+
* keys ("<bucket>/<uuid>"), so one physical bucket per environment suffices.
|
|
120
|
+
*/
|
|
121
|
+
function platformS3Backend(
|
|
122
|
+
env: Record<string, string | undefined>,
|
|
123
|
+
): ResolvedBackend | undefined {
|
|
124
|
+
const endpoint = env['BUNDERSTACK_S3_ENDPOINT']
|
|
125
|
+
if (!endpoint) return undefined
|
|
126
|
+
return {
|
|
127
|
+
type: 's3',
|
|
128
|
+
bucket: env['BUNDERSTACK_S3_BUCKET'] ?? '',
|
|
129
|
+
region: env['BUNDERSTACK_S3_REGION'] ?? 'auto',
|
|
130
|
+
endpoint,
|
|
131
|
+
accessKeyId: env['BUNDERSTACK_S3_ACCESS_KEY_ID'] ?? '',
|
|
132
|
+
secretAccessKey: env['BUNDERSTACK_S3_SECRET_ACCESS_KEY'] ?? '',
|
|
133
|
+
publicUrl: env['BUNDERSTACK_S3_PUBLIC_URL'],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
// ---------------------------------------------------------------------------
|
|
112
138
|
// Shared backend resolution
|
|
113
139
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +175,9 @@ function resolveBucketBackend(
|
|
|
149
175
|
sharedBackend: ResolvedBackend,
|
|
150
176
|
env: Record<string, string | undefined>,
|
|
151
177
|
): ResolvedBackend {
|
|
178
|
+
// Platform override active → sharedBackend IS the platform backend and
|
|
179
|
+
// code-level per-bucket backends are ignored.
|
|
180
|
+
if (env['BUNDERSTACK_S3_ENDPOINT']) return sharedBackend
|
|
152
181
|
if ('s3' in bucketInput && bucketInput.s3 !== undefined) {
|
|
153
182
|
const block = bucketInput.s3
|
|
154
183
|
return {
|
|
@@ -232,7 +261,7 @@ export function resolveBuckets(
|
|
|
232
261
|
input: StorageConfigInput | undefined,
|
|
233
262
|
env: Record<string, string | undefined> = process.env,
|
|
234
263
|
): ResolvedStorageBuckets {
|
|
235
|
-
const sharedBackend = resolveSharedBackend(input, env)
|
|
264
|
+
const sharedBackend = platformS3Backend(env) ?? resolveSharedBackend(input, env)
|
|
236
265
|
const bucketsInput = input?.buckets
|
|
237
266
|
|
|
238
267
|
const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
|
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) {
|