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/README.md
CHANGED
|
@@ -27,6 +27,39 @@ Bun.serve({ fetch: app.handler })
|
|
|
27
27
|
Full documentation and examples:
|
|
28
28
|
[github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
|
|
29
29
|
|
|
30
|
+
## Platform deployment contract
|
|
31
|
+
|
|
32
|
+
Deployment platforms (like Bunderhost) integrate with any bunderstack app
|
|
33
|
+
through env vars alone — no code changes required.
|
|
34
|
+
|
|
35
|
+
### Overrides (beat code-level config)
|
|
36
|
+
|
|
37
|
+
| Var | Effect |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
|
|
40
|
+
| `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
|
|
41
|
+
| `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
|
|
42
|
+
| `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
|
|
43
|
+
| `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
|
|
44
|
+
| `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
|
|
45
|
+
| `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
|
|
46
|
+
|
|
47
|
+
Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
|
|
48
|
+
code-level config wins over.
|
|
49
|
+
|
|
50
|
+
### Introspection
|
|
51
|
+
|
|
52
|
+
Set `BUNDERSTACK_INTROSPECT=1` and import the app declaration: the boot is
|
|
53
|
+
guaranteed offline (in-memory database, no Redis) and missing user env vars
|
|
54
|
+
don't throw. Then read `app.manifest`:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
process.env.BUNDERSTACK_INTROSPECT = '1'
|
|
58
|
+
const { app } = await import('./src/bunderstack')
|
|
59
|
+
console.log(JSON.stringify(app.manifest))
|
|
60
|
+
// { dialect, tables, defaultBucket, buckets, realtime, env: { server, client } }
|
|
61
|
+
```
|
|
62
|
+
|
|
30
63
|
## Shipping TypeScript source
|
|
31
64
|
|
|
32
65
|
This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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?:
|
|
@@ -114,6 +118,12 @@ export type ResolvedConfig = {
|
|
|
114
118
|
export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
115
119
|
options: BunderstackConfig<TSchema>,
|
|
116
120
|
env?: BaseEnv,
|
|
121
|
+
// Platform-injected overrides (Bunderhost & co.) beat code-level config so
|
|
122
|
+
// apps with hardcoded local urls deploy unchanged.
|
|
123
|
+
platformSource: Record<string, string | undefined> = process.env as Record<
|
|
124
|
+
string,
|
|
125
|
+
string | undefined
|
|
126
|
+
>,
|
|
117
127
|
): ResolvedConfig {
|
|
118
128
|
const parsed = BunderstackOptionsSchema.parse(options)
|
|
119
129
|
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
@@ -123,8 +133,15 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
123
133
|
|
|
124
134
|
return {
|
|
125
135
|
database: {
|
|
126
|
-
url:
|
|
127
|
-
|
|
136
|
+
url:
|
|
137
|
+
platformSource['BUNDERSTACK_DATABASE_URL'] ??
|
|
138
|
+
parsed.database?.url ??
|
|
139
|
+
resolvedEnv.DATABASE_URL,
|
|
140
|
+
authToken:
|
|
141
|
+
platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
|
|
142
|
+
parsed.database?.authToken ??
|
|
143
|
+
resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
144
|
+
migrations: parsed.database?.migrations ?? './migrations',
|
|
128
145
|
},
|
|
129
146
|
auth: (() => {
|
|
130
147
|
const authInput = options.auth ?? {}
|
|
@@ -133,7 +150,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
133
150
|
secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
|
|
134
151
|
}
|
|
135
152
|
})(),
|
|
136
|
-
storage: resolveBuckets(options.storage),
|
|
153
|
+
storage: resolveBuckets(options.storage, platformSource),
|
|
137
154
|
realtime: parsed.realtime,
|
|
138
155
|
}
|
|
139
156
|
}
|
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,
|
|
@@ -117,7 +120,10 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
|
|
|
117
120
|
validateSection(envConfig?.server, 'server', source, issues, userVars)
|
|
118
121
|
validateSection(envConfig?.client, 'client', source, issues, userVars)
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
// Introspection (Bunderhost builder) imports the app declaration to read
|
|
124
|
+
// its manifest; missing user env must not kill the boot there.
|
|
125
|
+
const lenient = source.BUNDERSTACK_INTROSPECT === '1'
|
|
126
|
+
if (issues.length > 0 && !lenient) throw new BunderstackEnvError(issues)
|
|
121
127
|
return { ...base, ...userVars } as ValidatedEnv<TEnv>
|
|
122
128
|
}
|
|
123
129
|
|
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
|
}
|