bunderstack 0.17.0-beta.2 → 0.17.0-beta.4

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.
Files changed (336) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +11 -1
  3. package/dist/access.d.ts +116 -0
  4. package/dist/access.d.ts.map +1 -0
  5. package/dist/access.js +325 -0
  6. package/dist/access.js.map +1 -0
  7. package/dist/api/builder.d.ts +145 -0
  8. package/dist/api/builder.d.ts.map +1 -0
  9. package/dist/api/builder.js +28 -0
  10. package/dist/api/builder.js.map +1 -0
  11. package/dist/api/context.d.ts +34 -0
  12. package/dist/api/context.d.ts.map +1 -0
  13. package/dist/api/context.js +32 -0
  14. package/dist/api/context.js.map +1 -0
  15. package/dist/api/crud-router.d.ts +290 -0
  16. package/dist/api/crud-router.d.ts.map +1 -0
  17. package/dist/api/crud-router.js +249 -0
  18. package/dist/api/crud-router.js.map +1 -0
  19. package/dist/api/openapi.d.ts +6 -0
  20. package/dist/api/openapi.d.ts.map +1 -0
  21. package/dist/api/openapi.js +146 -0
  22. package/dist/api/openapi.js.map +1 -0
  23. package/dist/api/realtime-router.d.ts +76 -0
  24. package/dist/api/realtime-router.d.ts.map +1 -0
  25. package/dist/api/realtime-router.js +41 -0
  26. package/dist/api/realtime-router.js.map +1 -0
  27. package/dist/api/registry.d.ts +30 -0
  28. package/dist/api/registry.d.ts.map +1 -0
  29. package/dist/api/registry.js +239 -0
  30. package/dist/api/registry.js.map +1 -0
  31. package/dist/api/router.d.ts +9 -0
  32. package/dist/api/router.d.ts.map +1 -0
  33. package/dist/api/router.js +19 -0
  34. package/dist/api/router.js.map +1 -0
  35. package/dist/api/storage-router.d.ts +279 -0
  36. package/dist/api/storage-router.d.ts.map +1 -0
  37. package/dist/api/storage-router.js +155 -0
  38. package/dist/api/storage-router.js.map +1 -0
  39. package/dist/api/types.d.ts +55 -0
  40. package/dist/api/types.d.ts.map +1 -0
  41. package/dist/api/types.js +2 -0
  42. package/dist/api/types.js.map +1 -0
  43. package/dist/auth.d.ts +31 -0
  44. package/dist/auth.d.ts.map +1 -0
  45. package/dist/auth.js +102 -0
  46. package/dist/auth.js.map +1 -0
  47. package/dist/blueprint-generator.d.ts +18 -0
  48. package/dist/blueprint-generator.d.ts.map +1 -0
  49. package/dist/blueprint-generator.js +125 -0
  50. package/dist/blueprint-generator.js.map +1 -0
  51. package/dist/blueprint.d.ts +46 -0
  52. package/dist/blueprint.d.ts.map +1 -0
  53. package/dist/blueprint.js +162 -0
  54. package/dist/blueprint.js.map +1 -0
  55. package/dist/cli.d.ts +8 -0
  56. package/dist/cli.d.ts.map +1 -0
  57. package/dist/cli.js +68 -0
  58. package/dist/cli.js.map +1 -0
  59. package/dist/config.d.ts +108 -0
  60. package/dist/config.d.ts.map +1 -0
  61. package/dist/config.js +90 -0
  62. package/dist/config.js.map +1 -0
  63. package/dist/cron.d.ts +4 -0
  64. package/dist/cron.d.ts.map +1 -0
  65. package/dist/cron.js +3 -0
  66. package/dist/cron.js.map +1 -0
  67. package/dist/crud-operations.d.ts +43 -0
  68. package/dist/crud-operations.d.ts.map +1 -0
  69. package/dist/crud-operations.js +238 -0
  70. package/dist/crud-operations.js.map +1 -0
  71. package/dist/database/adapter.d.ts +20 -0
  72. package/dist/database/adapter.d.ts.map +1 -0
  73. package/dist/database/adapter.js +2 -0
  74. package/dist/database/adapter.js.map +1 -0
  75. package/dist/database/bun-sql.d.ts +3 -0
  76. package/dist/database/bun-sql.d.ts.map +1 -0
  77. package/dist/database/bun-sql.js +21 -0
  78. package/dist/database/bun-sql.js.map +1 -0
  79. package/dist/database/libsql.d.ts +3 -0
  80. package/dist/database/libsql.d.ts.map +1 -0
  81. package/dist/database/libsql.js +22 -0
  82. package/dist/database/libsql.js.map +1 -0
  83. package/dist/database/pglite.d.ts +3 -0
  84. package/dist/database/pglite.d.ts.map +1 -0
  85. package/dist/database/pglite.js +27 -0
  86. package/dist/database/pglite.js.map +1 -0
  87. package/dist/database/postgres-js.d.ts +3 -0
  88. package/dist/database/postgres-js.d.ts.map +1 -0
  89. package/dist/database/postgres-js.js +21 -0
  90. package/dist/database/postgres-js.js.map +1 -0
  91. package/dist/db.d.ts +18 -0
  92. package/dist/db.d.ts.map +1 -0
  93. package/dist/db.js +26 -0
  94. package/dist/db.js.map +1 -0
  95. package/dist/dialect.d.ts +17 -0
  96. package/dist/dialect.d.ts.map +1 -0
  97. package/dist/dialect.js +22 -0
  98. package/dist/dialect.js.map +1 -0
  99. package/dist/email/smtp.d.ts +14 -0
  100. package/dist/email/smtp.d.ts.map +1 -0
  101. package/dist/email/smtp.js +27 -0
  102. package/dist/email/smtp.js.map +1 -0
  103. package/dist/email.d.ts +39 -0
  104. package/dist/email.d.ts.map +1 -0
  105. package/dist/email.js +97 -0
  106. package/dist/email.js.map +1 -0
  107. package/dist/env.d.ts +47 -0
  108. package/dist/env.d.ts.map +1 -0
  109. package/dist/env.js +99 -0
  110. package/dist/env.js.map +1 -0
  111. package/dist/errors.d.ts +125 -0
  112. package/dist/errors.d.ts.map +1 -0
  113. package/dist/errors.js +92 -0
  114. package/dist/errors.js.map +1 -0
  115. package/dist/handler.d.ts +9 -0
  116. package/dist/handler.d.ts.map +1 -0
  117. package/dist/handler.js +22 -0
  118. package/dist/handler.js.map +1 -0
  119. package/dist/idempotency.d.ts +17 -0
  120. package/dist/idempotency.d.ts.map +1 -0
  121. package/dist/idempotency.js +65 -0
  122. package/dist/idempotency.js.map +1 -0
  123. package/dist/index.d.ts +134 -0
  124. package/dist/index.d.ts.map +1 -0
  125. package/dist/index.js +480 -0
  126. package/dist/index.js.map +1 -0
  127. package/dist/internal-tables-pg.d.ts +481 -0
  128. package/dist/internal-tables-pg.d.ts.map +1 -0
  129. package/{src/internal-tables-pg.ts → dist/internal-tables-pg.js} +10 -34
  130. package/dist/internal-tables-pg.js.map +1 -0
  131. package/dist/internal-tables.d.ts +2509 -0
  132. package/dist/internal-tables.d.ts.map +1 -0
  133. package/dist/internal-tables.js +108 -0
  134. package/dist/internal-tables.js.map +1 -0
  135. package/dist/jobs/cron.d.ts +15 -0
  136. package/dist/jobs/cron.d.ts.map +1 -0
  137. package/dist/jobs/cron.js +74 -0
  138. package/dist/jobs/cron.js.map +1 -0
  139. package/dist/jobs/define.d.ts +133 -0
  140. package/dist/jobs/define.d.ts.map +1 -0
  141. package/dist/jobs/define.js +74 -0
  142. package/dist/jobs/define.js.map +1 -0
  143. package/dist/jobs/index.d.ts +11 -0
  144. package/dist/jobs/index.d.ts.map +1 -0
  145. package/dist/jobs/index.js +8 -0
  146. package/dist/jobs/index.js.map +1 -0
  147. package/dist/jobs/queue.d.ts +6 -0
  148. package/dist/jobs/queue.d.ts.map +1 -0
  149. package/dist/jobs/queue.js +55 -0
  150. package/dist/jobs/queue.js.map +1 -0
  151. package/dist/jobs/runtime.d.ts +13 -0
  152. package/dist/jobs/runtime.d.ts.map +1 -0
  153. package/dist/jobs/runtime.js +46 -0
  154. package/dist/jobs/runtime.js.map +1 -0
  155. package/dist/jobs/slots.d.ts +25 -0
  156. package/dist/jobs/slots.d.ts.map +1 -0
  157. package/dist/jobs/slots.js +41 -0
  158. package/dist/jobs/slots.js.map +1 -0
  159. package/dist/jobs/worker.d.ts +12 -0
  160. package/dist/jobs/worker.d.ts.map +1 -0
  161. package/dist/jobs/worker.js +311 -0
  162. package/dist/jobs/worker.js.map +1 -0
  163. package/dist/lifecycle.d.ts +10 -0
  164. package/dist/lifecycle.d.ts.map +1 -0
  165. package/dist/lifecycle.js +37 -0
  166. package/dist/lifecycle.js.map +1 -0
  167. package/dist/list-query.d.ts +62 -0
  168. package/dist/list-query.d.ts.map +1 -0
  169. package/dist/list-query.js +194 -0
  170. package/dist/list-query.js.map +1 -0
  171. package/dist/manifest.d.ts +59 -0
  172. package/dist/manifest.d.ts.map +1 -0
  173. package/dist/manifest.js +167 -0
  174. package/dist/manifest.js.map +1 -0
  175. package/dist/provision-internals.d.ts +24 -0
  176. package/dist/provision-internals.d.ts.map +1 -0
  177. package/dist/provision-internals.js +7 -0
  178. package/dist/provision-internals.js.map +1 -0
  179. package/dist/provision.d.ts +23 -0
  180. package/dist/provision.d.ts.map +1 -0
  181. package/dist/provision.js +96 -0
  182. package/dist/provision.js.map +1 -0
  183. package/dist/rate-limit.d.ts +7 -0
  184. package/dist/rate-limit.d.ts.map +1 -0
  185. package/dist/rate-limit.js +49 -0
  186. package/dist/rate-limit.js.map +1 -0
  187. package/dist/realtime/facade.d.ts +18 -0
  188. package/dist/realtime/facade.d.ts.map +1 -0
  189. package/dist/realtime/facade.js +36 -0
  190. package/dist/realtime/facade.js.map +1 -0
  191. package/dist/realtime/filter.d.ts +13 -0
  192. package/dist/realtime/filter.d.ts.map +1 -0
  193. package/dist/realtime/filter.js +48 -0
  194. package/dist/realtime/filter.js.map +1 -0
  195. package/dist/realtime/heartbeat.d.ts +14 -0
  196. package/dist/realtime/heartbeat.d.ts.map +1 -0
  197. package/dist/realtime/heartbeat.js +64 -0
  198. package/dist/realtime/heartbeat.js.map +1 -0
  199. package/dist/realtime/publisher.d.ts +21 -0
  200. package/dist/realtime/publisher.d.ts.map +1 -0
  201. package/dist/realtime/publisher.js +17 -0
  202. package/dist/realtime/publisher.js.map +1 -0
  203. package/dist/schema-export-pg.d.ts +2 -0
  204. package/dist/schema-export-pg.d.ts.map +1 -0
  205. package/dist/schema-export-pg.js +4 -0
  206. package/dist/schema-export-pg.js.map +1 -0
  207. package/dist/schema-export.d.ts +2 -0
  208. package/dist/schema-export.d.ts.map +1 -0
  209. package/dist/schema-export.js +2 -0
  210. package/dist/schema-export.js.map +1 -0
  211. package/dist/scope.d.ts +4 -0
  212. package/dist/scope.d.ts.map +1 -0
  213. package/dist/scope.js +13 -0
  214. package/dist/scope.js.map +1 -0
  215. package/dist/standard-schema.d.ts +13 -0
  216. package/dist/standard-schema.d.ts.map +1 -0
  217. package/dist/standard-schema.js +34 -0
  218. package/dist/standard-schema.js.map +1 -0
  219. package/dist/storage/buckets.d.ts +80 -0
  220. package/dist/storage/buckets.d.ts.map +1 -0
  221. package/dist/storage/buckets.js +178 -0
  222. package/dist/storage/buckets.js.map +1 -0
  223. package/dist/storage/delete.d.ts +11 -0
  224. package/dist/storage/delete.d.ts.map +1 -0
  225. package/dist/storage/delete.js +18 -0
  226. package/dist/storage/delete.js.map +1 -0
  227. package/dist/storage/file-meta.d.ts +37 -0
  228. package/dist/storage/file-meta.d.ts.map +1 -0
  229. package/dist/storage/file-meta.js +166 -0
  230. package/dist/storage/file-meta.js.map +1 -0
  231. package/dist/storage/index.d.ts +28 -0
  232. package/dist/storage/index.d.ts.map +1 -0
  233. package/dist/storage/index.js +5 -0
  234. package/dist/storage/index.js.map +1 -0
  235. package/dist/storage/local.d.ts +15 -0
  236. package/dist/storage/local.d.ts.map +1 -0
  237. package/dist/storage/local.js +56 -0
  238. package/dist/storage/local.js.map +1 -0
  239. package/dist/storage/operations.d.ts +54 -0
  240. package/dist/storage/operations.d.ts.map +1 -0
  241. package/dist/storage/operations.js +250 -0
  242. package/dist/storage/operations.js.map +1 -0
  243. package/dist/storage/registry.d.ts +10 -0
  244. package/dist/storage/registry.d.ts.map +1 -0
  245. package/dist/storage/registry.js +23 -0
  246. package/dist/storage/registry.js.map +1 -0
  247. package/dist/storage/s3.d.ts +28 -0
  248. package/dist/storage/s3.d.ts.map +1 -0
  249. package/dist/storage/s3.js +71 -0
  250. package/dist/storage/s3.js.map +1 -0
  251. package/dist/storage/sweep.d.ts +9 -0
  252. package/dist/storage/sweep.d.ts.map +1 -0
  253. package/dist/storage/sweep.js +17 -0
  254. package/dist/storage/sweep.js.map +1 -0
  255. package/dist/storage/thumbnails.d.ts +11 -0
  256. package/dist/storage/thumbnails.d.ts.map +1 -0
  257. package/dist/storage/thumbnails.js +60 -0
  258. package/dist/storage/thumbnails.js.map +1 -0
  259. package/dist/testing.d.ts +27 -0
  260. package/dist/testing.d.ts.map +1 -0
  261. package/dist/testing.js +11 -0
  262. package/dist/testing.js.map +1 -0
  263. package/dist/typeid-pg.d.ts +19 -0
  264. package/dist/typeid-pg.d.ts.map +1 -0
  265. package/{src/typeid-pg.ts → dist/typeid-pg.js} +10 -13
  266. package/dist/typeid-pg.js.map +1 -0
  267. package/dist/typeid.d.ts +45 -0
  268. package/dist/typeid.d.ts.map +1 -0
  269. package/dist/typeid.js +100 -0
  270. package/dist/typeid.js.map +1 -0
  271. package/package.json +77 -28
  272. package/src/access.ts +0 -532
  273. package/src/api/builder.ts +0 -52
  274. package/src/api/context.ts +0 -83
  275. package/src/api/crud-router.ts +0 -386
  276. package/src/api/openapi.ts +0 -184
  277. package/src/api/realtime-router.ts +0 -75
  278. package/src/api/registry.ts +0 -338
  279. package/src/api/router.ts +0 -34
  280. package/src/api/storage-router.ts +0 -224
  281. package/src/api/types.ts +0 -105
  282. package/src/auth.ts +0 -117
  283. package/src/blueprint-generator.ts +0 -203
  284. package/src/blueprint.ts +0 -264
  285. package/src/cli.ts +0 -90
  286. package/src/config.ts +0 -215
  287. package/src/cron.ts +0 -3
  288. package/src/crud-operations.ts +0 -478
  289. package/src/database/adapter.ts +0 -26
  290. package/src/database/bun-sql.ts +0 -23
  291. package/src/database/libsql.ts +0 -28
  292. package/src/database/pglite.ts +0 -33
  293. package/src/database/postgres-js.ts +0 -33
  294. package/src/db.ts +0 -61
  295. package/src/dialect.ts +0 -38
  296. package/src/email/smtp.ts +0 -45
  297. package/src/email.ts +0 -154
  298. package/src/env.ts +0 -180
  299. package/src/errors.ts +0 -123
  300. package/src/handler.ts +0 -30
  301. package/src/idempotency.ts +0 -97
  302. package/src/index.ts +0 -834
  303. package/src/internal-tables.ts +0 -149
  304. package/src/jobs/cron.ts +0 -87
  305. package/src/jobs/define.ts +0 -283
  306. package/src/jobs/index.ts +0 -37
  307. package/src/jobs/queue.ts +0 -67
  308. package/src/jobs/runtime.ts +0 -58
  309. package/src/jobs/slots.ts +0 -52
  310. package/src/jobs/worker.ts +0 -369
  311. package/src/lifecycle.ts +0 -49
  312. package/src/list-query.ts +0 -333
  313. package/src/manifest.ts +0 -297
  314. package/src/provision-internals.ts +0 -29
  315. package/src/provision.ts +0 -128
  316. package/src/rate-limit.ts +0 -71
  317. package/src/realtime/facade.ts +0 -74
  318. package/src/realtime/filter.ts +0 -81
  319. package/src/realtime/heartbeat.ts +0 -80
  320. package/src/realtime/publisher.ts +0 -46
  321. package/src/schema-export-pg.ts +0 -7
  322. package/src/schema-export.ts +0 -5
  323. package/src/scope.ts +0 -17
  324. package/src/standard-schema.ts +0 -59
  325. package/src/storage/buckets.ts +0 -308
  326. package/src/storage/delete.ts +0 -26
  327. package/src/storage/file-meta.ts +0 -222
  328. package/src/storage/index.ts +0 -39
  329. package/src/storage/local.ts +0 -69
  330. package/src/storage/operations.ts +0 -398
  331. package/src/storage/registry.ts +0 -40
  332. package/src/storage/s3.ts +0 -99
  333. package/src/storage/sweep.ts +0 -25
  334. package/src/storage/thumbnails.ts +0 -65
  335. package/src/testing.ts +0 -31
  336. package/src/typeid.ts +0 -116
@@ -1,398 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { extname } from 'node:path'
3
-
4
- import type { AccessContext, OperationRule } from '../access'
5
- import type { AnyDb } from '../dialect'
6
- import type { BucketStorageRegistry } from './registry'
7
-
8
- import { checkAccess } from '../access'
9
- import { BunderstackError } from '../errors'
10
- import { deleteFileWithDerivatives } from './delete'
11
- import {
12
- deleteFileMetaRow,
13
- fileMatchesScope,
14
- getFileMeta,
15
- insertPendingFile,
16
- insertReadyFile,
17
- markFileReady,
18
- scopeToJson,
19
- sumReadySize,
20
- type FileMetaRow,
21
- } from './file-meta'
22
- import { parseTransformSpec, transformHash, transformImage } from './thumbnails'
23
-
24
- export interface StorageExecutionContext {
25
- request: Request
26
- user: AccessContext['user']
27
- session: { activeOrganizationId: string | null }
28
- }
29
-
30
- export interface StorageOperationsOptions {
31
- registry: BucketStorageRegistry
32
- db: AnyDb
33
- presignExpiresSec?: number
34
- }
35
-
36
- export type PrepareUploadResult =
37
- | { mode: 'proxy'; uploadUrl: string }
38
- | {
39
- mode: 'presign'
40
- fileId: string
41
- uploadUrl: string
42
- method: 'PUT'
43
- confirmUrl: string
44
- }
45
-
46
- export type StorageDownload =
47
- | { kind: 'redirect'; status: 302; url: string }
48
- | {
49
- kind: 'body'
50
- status: number
51
- body: ConstructorParameters<typeof Response>[0]
52
- headers: Headers
53
- }
54
-
55
- const FILE_OWNER_COLUMN = 'ownerId'
56
-
57
- function matchMime(type: string, accept?: string[]): boolean {
58
- if (!accept || accept.length === 0) return true
59
- if (!type) return false
60
- return accept.some(
61
- (pattern) =>
62
- pattern === type ||
63
- (pattern.endsWith('/*') && type.startsWith(pattern.slice(0, -1))),
64
- )
65
- }
66
-
67
- function accessContext(
68
- context: StorageExecutionContext,
69
- extra: { row?: FileMetaRow; body?: Record<string, unknown> } = {},
70
- ): AccessContext {
71
- return {
72
- request: context.request,
73
- user: context.user,
74
- session: context.session,
75
- row: extra.row,
76
- body: extra.body,
77
- }
78
- }
79
-
80
- async function gate(rule: OperationRule, context: AccessContext) {
81
- const result = await checkAccess(rule, context, FILE_OWNER_COLUMN)
82
- if (!result.allowed) {
83
- throw new BunderstackError(
84
- result.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
85
- result.status === 401 ? 'Authentication required' : 'Forbidden',
86
- )
87
- }
88
- }
89
-
90
- function notFound(message = 'Not found'): never {
91
- throw new BunderstackError('NOT_FOUND', message)
92
- }
93
-
94
- function sanitizeFilename(name: string): string {
95
- return name.replace(/["\\\r\n]/g, '')
96
- }
97
-
98
- async function quotaExceeded(
99
- db: AnyDb,
100
- bucket: string,
101
- quota: { perUserBytes?: number; perScopeBytes?: number },
102
- ownerId: string | undefined,
103
- scopeJson: string | null,
104
- incoming: number,
105
- ): Promise<boolean> {
106
- if (quota.perUserBytes !== undefined && ownerId !== undefined) {
107
- const current = await sumReadySize(db, { bucket, ownerId })
108
- if (current + incoming > quota.perUserBytes) return true
109
- }
110
- if (quota.perScopeBytes !== undefined && scopeJson != null) {
111
- const current = await sumReadySize(db, { bucket, scopeJson })
112
- if (current + incoming > quota.perScopeBytes) return true
113
- }
114
- return false
115
- }
116
-
117
- export function createStorageOperations(options: StorageOperationsOptions) {
118
- const { registry, db } = options
119
- const presignExpiresSec = options.presignExpiresSec ?? 60
120
-
121
- const bucketEntry = (name: string) => {
122
- const entry = registry.get(name)
123
- if (!entry) notFound('Unknown bucket')
124
- return entry
125
- }
126
-
127
- return {
128
- async prepareUpload(
129
- bucketName: string,
130
- body: { filename?: string; contentType?: string },
131
- context: StorageExecutionContext,
132
- ): Promise<PrepareUploadResult> {
133
- const { bucket, adapter } = bucketEntry(bucketName)
134
- const ctx = accessContext(context, { body })
135
- await gate(bucket.access.create, ctx)
136
-
137
- if (!adapter.presignPut) {
138
- return { mode: 'proxy', uploadUrl: `/api/files/${bucket.name}` }
139
- }
140
-
141
- const scopeJson = scopeToJson(bucket.writeScope?.(ctx))
142
- if (
143
- bucket.quota &&
144
- (await quotaExceeded(
145
- db,
146
- bucket.name,
147
- bucket.quota,
148
- context.user?.id,
149
- scopeJson,
150
- bucket.upload?.maxSizeBytes ?? 0,
151
- ))
152
- ) {
153
- throw new BunderstackError('PAYLOAD_TOO_LARGE', 'Quota exceeded')
154
- }
155
-
156
- const fileId = `${bucket.name}/${randomUUID()}${extname(body.filename ?? '')}`
157
- await insertPendingFile(db, {
158
- fileId,
159
- bucket: bucket.name,
160
- ownerId: context.user?.id ?? null,
161
- scopeJson,
162
- filename: body.filename ?? null,
163
- contentType: body.contentType ?? null,
164
- })
165
- const uploadUrl = await adapter.presignPut(fileId, {
166
- contentType: body.contentType,
167
- expiresIn: presignExpiresSec,
168
- })
169
- const id = fileId.slice(`${bucket.name}/`.length)
170
- return {
171
- mode: 'presign',
172
- fileId,
173
- uploadUrl,
174
- method: 'PUT',
175
- confirmUrl: `/api/files/${bucket.name}/${id}/confirm`,
176
- }
177
- },
178
-
179
- async upload(
180
- bucketName: string,
181
- file: File,
182
- context: StorageExecutionContext,
183
- ) {
184
- const { bucket, adapter } = bucketEntry(bucketName)
185
- const ctx = accessContext(context)
186
- await gate(bucket.access.create, ctx)
187
-
188
- if (!matchMime(file.type, bucket.upload?.accept)) {
189
- throw new BunderstackError(
190
- 'BAD_REQUEST',
191
- `Content type ${file.type || '(none)'} not allowed`,
192
- )
193
- }
194
- if (
195
- bucket.upload?.maxSizeBytes !== undefined &&
196
- file.size > bucket.upload.maxSizeBytes
197
- ) {
198
- throw new BunderstackError('PAYLOAD_TOO_LARGE', 'File too large')
199
- }
200
-
201
- const scopeJson = scopeToJson(bucket.readScope?.(ctx))
202
- if (
203
- bucket.quota &&
204
- (await quotaExceeded(
205
- db,
206
- bucket.name,
207
- bucket.quota,
208
- context.user?.id,
209
- scopeJson,
210
- file.size,
211
- ))
212
- ) {
213
- throw new BunderstackError('PAYLOAD_TOO_LARGE', 'Quota exceeded')
214
- }
215
-
216
- const fileId = `${bucket.name}/${randomUUID()}${extname(file.name)}`
217
- await adapter.upload(fileId, await file.arrayBuffer(), file.type)
218
- await insertReadyFile(db, {
219
- fileId,
220
- bucket: bucket.name,
221
- ownerId: context.user?.id ?? null,
222
- scopeJson,
223
- filename: file.name || null,
224
- contentType: file.type || null,
225
- size: file.size,
226
- })
227
- const id = fileId.slice(`${bucket.name}/`.length)
228
- return {
229
- status: 201 as const,
230
- fileId,
231
- url: `/api/files/${bucket.name}/${id}`,
232
- }
233
- },
234
-
235
- async confirmUpload(
236
- bucketName: string,
237
- id: string,
238
- context: StorageExecutionContext,
239
- ) {
240
- const { bucket, adapter } = bucketEntry(bucketName)
241
- const fileId = `${bucketName}/${id}`
242
- const row = await getFileMeta(db, fileId)
243
- if (!row || row.bucket !== bucketName) notFound()
244
- if (row.ownerId != null && row.ownerId !== (context.user?.id ?? null)) {
245
- throw new BunderstackError('FORBIDDEN', 'Forbidden')
246
- }
247
-
248
- const result = { fileId, url: `/api/files/${bucketName}/${id}` }
249
- if (row.status === 'ready') return result
250
-
251
- const info = await adapter.stat?.(fileId)
252
- if (!info) notFound()
253
-
254
- const rejectUpload = async (message: string, validation = false) => {
255
- await adapter.delete(fileId)
256
- await deleteFileMetaRow(db, fileId)
257
- throw new BunderstackError(
258
- validation ? 'BAD_REQUEST' : 'PAYLOAD_TOO_LARGE',
259
- message,
260
- )
261
- }
262
- if (
263
- bucket.upload?.maxSizeBytes !== undefined &&
264
- info.size > bucket.upload.maxSizeBytes
265
- ) {
266
- await rejectUpload('File too large')
267
- }
268
- if (!matchMime(info.contentType, bucket.upload?.accept)) {
269
- await rejectUpload(
270
- `Content type ${info.contentType || '(none)'} not allowed`,
271
- true,
272
- )
273
- }
274
- if (
275
- bucket.quota &&
276
- (await quotaExceeded(
277
- db,
278
- bucket.name,
279
- bucket.quota,
280
- row.ownerId ?? undefined,
281
- row.scopeJson,
282
- info.size,
283
- ))
284
- ) {
285
- await rejectUpload('Quota exceeded')
286
- }
287
-
288
- await markFileReady(db, fileId, info)
289
- return result
290
- },
291
-
292
- async download(
293
- bucketName: string,
294
- id: string,
295
- query: Record<string, string>,
296
- context: StorageExecutionContext,
297
- ): Promise<StorageDownload> {
298
- const { bucket, adapter } = bucketEntry(bucketName)
299
- const fileId = `${bucketName}/${id}`
300
- const row = await getFileMeta(db, fileId)
301
- if (!row || row.status !== 'ready' || row.bucket !== bucketName) notFound()
302
-
303
- const ctx = accessContext(context, { row })
304
- await gate(bucket.access.get, ctx)
305
- if (!fileMatchesScope(row, bucket.readScope?.(ctx))) notFound()
306
-
307
- const spec = parseTransformSpec(query)
308
- if (spec) {
309
- if (!bucket.transforms) {
310
- throw new BunderstackError(
311
- 'BAD_REQUEST',
312
- 'Transforms not enabled for this bucket',
313
- )
314
- }
315
- const ext = spec.format ? `.${spec.format}` : extname(fileId) || '.jpg'
316
- const cacheKey = `${fileId}__transforms/${transformHash(spec)}${ext}`
317
- if (await adapter.exists(cacheKey)) {
318
- const cached = await adapter.get(cacheKey)
319
- const headers = new Headers(cached.headers)
320
- headers.set('Cache-Control', 'public, max-age=31536000')
321
- return {
322
- kind: 'body',
323
- status: cached.status,
324
- body: cached.body,
325
- headers,
326
- }
327
- }
328
-
329
- const original = await adapter.get(fileId)
330
- if (original.status === 404) notFound()
331
- const transformed = await transformImage(
332
- Buffer.from(await original.arrayBuffer()),
333
- spec,
334
- )
335
- const contentType = spec.format
336
- ? `image/${spec.format}`
337
- : (original.headers.get('Content-Type') ?? 'image/jpeg')
338
- const body = Uint8Array.from(transformed).buffer
339
- await adapter.upload(cacheKey, body, contentType)
340
- return {
341
- kind: 'body',
342
- status: 200,
343
- body,
344
- headers: new Headers({
345
- 'Content-Type': contentType,
346
- 'Cache-Control': 'public, max-age=31536000',
347
- }),
348
- }
349
- }
350
-
351
- if (bucket.visibility === 'public' && adapter.publicUrlFor) {
352
- const url = adapter.publicUrlFor(fileId)
353
- if (url) return { kind: 'redirect', status: 302, url }
354
- }
355
- if (bucket.visibility === 'private' && adapter.presignGet) {
356
- return {
357
- kind: 'redirect',
358
- status: 302,
359
- url: await adapter.presignGet(fileId, {
360
- expiresIn: presignExpiresSec,
361
- }),
362
- }
363
- }
364
-
365
- const response = await adapter.get(fileId)
366
- const headers = new Headers(response.headers)
367
- if (response.status !== 404 && row.filename) {
368
- headers.set(
369
- 'Content-Disposition',
370
- `inline; filename="${sanitizeFilename(row.filename)}"`,
371
- )
372
- }
373
- return {
374
- kind: 'body',
375
- status: response.status,
376
- body: response.body,
377
- headers,
378
- }
379
- },
380
-
381
- async delete(
382
- bucketName: string,
383
- id: string,
384
- context: StorageExecutionContext,
385
- ): Promise<void> {
386
- const { bucket, adapter } = bucketEntry(bucketName)
387
- const fileId = `${bucketName}/${id}`
388
- const row = await getFileMeta(db, fileId)
389
- if (!row || row.bucket !== bucketName) notFound()
390
- const ctx = accessContext(context, { row })
391
- await gate(bucket.access.delete, ctx)
392
- if (!fileMatchesScope(row, bucket.readScope?.(ctx))) notFound()
393
- await deleteFileWithDerivatives(adapter, db, fileId)
394
- },
395
- }
396
- }
397
-
398
- export type StorageOperations = ReturnType<typeof createStorageOperations>
@@ -1,40 +0,0 @@
1
- // src/storage/registry.ts
2
- import type {
3
- ResolvedBackend,
4
- ResolvedBucket,
5
- ResolvedStorageBuckets,
6
- } from './buckets'
7
- import type { StorageAdapter } from './index'
8
-
9
- import { LocalStorageAdapter } from './local'
10
- import { S3StorageAdapter } from './s3'
11
-
12
- export interface BucketStorage {
13
- bucket: ResolvedBucket
14
- adapter: StorageAdapter
15
- }
16
- export type BucketStorageRegistry = Map<string, BucketStorage>
17
-
18
- export function createAdapter(backend: ResolvedBackend): StorageAdapter {
19
- if (backend.type === 'local') {
20
- return new LocalStorageAdapter(backend.path)
21
- }
22
- return new S3StorageAdapter({
23
- bucket: backend.bucket,
24
- region: backend.region,
25
- accessKeyId: backend.accessKeyId,
26
- secretAccessKey: backend.secretAccessKey,
27
- endpoint: backend.endpoint,
28
- publicUrl: backend.publicUrl,
29
- })
30
- }
31
-
32
- export function createBucketStorages(
33
- resolved: ResolvedStorageBuckets,
34
- ): BucketStorageRegistry {
35
- const registry: BucketStorageRegistry = new Map()
36
- for (const [name, bucket] of resolved.buckets) {
37
- registry.set(name, { bucket, adapter: createAdapter(bucket.backend) })
38
- }
39
- return registry
40
- }
package/src/storage/s3.ts DELETED
@@ -1,99 +0,0 @@
1
- // src/storage/s3.ts
2
- import type {
3
- PresignGetOptions,
4
- PresignPutOptions,
5
- StorageAdapter,
6
- } from './index'
7
-
8
- interface S3Config {
9
- bucket: string
10
- region: string
11
- accessKeyId: string
12
- secretAccessKey: string
13
- endpoint?: string
14
- publicUrl?: string
15
- }
16
-
17
- export class S3StorageAdapter implements StorageAdapter {
18
- private client: InstanceType<typeof Bun.S3Client>
19
- private readonly publicUrl?: string
20
-
21
- constructor(cfg: S3Config) {
22
- this.client = new Bun.S3Client({
23
- bucket: cfg.bucket,
24
- region: cfg.region,
25
- accessKeyId: cfg.accessKeyId,
26
- secretAccessKey: cfg.secretAccessKey,
27
- ...(cfg.endpoint && { endpoint: cfg.endpoint }),
28
- })
29
- this.publicUrl = cfg.publicUrl
30
- }
31
-
32
- async upload(
33
- fileId: string,
34
- data: Blob | ArrayBuffer,
35
- contentType: string,
36
- ): Promise<void> {
37
- const bytes = data instanceof Blob ? await data.arrayBuffer() : data
38
- await this.client.write(fileId, bytes, { type: contentType })
39
- }
40
-
41
- async get(fileId: string): Promise<Response> {
42
- // `stat` (a HEAD, same cost as `exists`) is the only way to learn the
43
- // stored Content-Type: a lazily-constructed `client.file(key)` reports
44
- // `type: ""` — it never stats, and unlike `Bun.file` it does not guess
45
- // from the extension either.
46
- const info = await this.stat(fileId)
47
- if (!info) return new Response('Not found', { status: 404 })
48
- const file = this.client.file(fileId)
49
- return new Response(file.stream(), {
50
- headers: {
51
- 'Content-Type': info.contentType || 'application/octet-stream',
52
- },
53
- })
54
- }
55
-
56
- async delete(fileId: string): Promise<void> {
57
- await this.client.delete(fileId)
58
- }
59
-
60
- async exists(fileId: string): Promise<boolean> {
61
- return this.client.exists(fileId)
62
- }
63
-
64
- async presignPut(key: string, opts: PresignPutOptions): Promise<string> {
65
- return this.client.presign(key, {
66
- method: 'PUT',
67
- expiresIn: opts.expiresIn,
68
- ...(opts.contentType && { type: opts.contentType }),
69
- })
70
- }
71
-
72
- async presignGet(key: string, opts: PresignGetOptions): Promise<string> {
73
- return this.client.presign(key, {
74
- method: 'GET',
75
- expiresIn: opts.expiresIn,
76
- })
77
- }
78
-
79
- async stat(
80
- key: string,
81
- ): Promise<{ size: number; contentType: string } | null> {
82
- try {
83
- const s = await this.client.stat(key)
84
- return { size: s.size, contentType: s.type }
85
- } catch {
86
- return null
87
- }
88
- }
89
-
90
- publicUrlFor(key: string): string | undefined {
91
- if (!this.publicUrl) return undefined
92
- return `${this.publicUrl.replace(/\/$/, '')}/${key}`
93
- }
94
-
95
- async list(prefix: string): Promise<string[]> {
96
- const res = await this.client.list({ prefix })
97
- return res.contents?.map((c) => c.key) ?? []
98
- }
99
- }
@@ -1,25 +0,0 @@
1
- // src/storage/sweep.ts
2
- import type { AnyDb } from '../dialect'
3
- import type { BucketStorageRegistry } from './registry'
4
-
5
- import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
6
-
7
- /**
8
- * Reap stale `pending` file-meta rows (two-phase uploads that never confirmed)
9
- * older than `olderThanMs`. For each, best-effort delete the object (it may
10
- * never have landed) then remove the meta row. Returns the number reaped.
11
- */
12
- export async function sweepOrphans(
13
- registry: BucketStorageRegistry,
14
- db: AnyDb,
15
- olderThanMs: number,
16
- ): Promise<number> {
17
- const cutoff = Date.now() - olderThanMs
18
- const rows = await listStalePendingFiles(db, cutoff)
19
- for (const row of rows) {
20
- const adapter = registry.get(row.bucket)?.adapter
21
- await adapter?.delete(row.fileId).catch(() => {})
22
- await deleteFileMetaRow(db, row.fileId)
23
- }
24
- return rows.length
25
- }
@@ -1,65 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
-
3
- export interface TransformSpec {
4
- w?: number
5
- h?: number
6
- fit?: 'fill' | 'inside'
7
- format?: 'webp' | 'jpeg' | 'png' | 'avif'
8
- quality?: number
9
- }
10
-
11
- export async function transformImage(
12
- input: Buffer,
13
- spec: TransformSpec,
14
- ): Promise<Buffer> {
15
- let img = new Bun.Image(input)
16
-
17
- if (spec.w !== undefined && spec.h !== undefined) {
18
- img = img.resize(spec.w, spec.h, { fit: spec.fit ?? 'fill' })
19
- } else if (spec.w !== undefined) {
20
- img = img.resize(spec.w)
21
- } else if (spec.h !== undefined) {
22
- img = img.resize(0, spec.h)
23
- }
24
-
25
- const q = spec.quality
26
- switch (spec.format) {
27
- case 'webp':
28
- return img.webp({ quality: q }).buffer()
29
- case 'png':
30
- return img.png().buffer()
31
- case 'avif':
32
- return img.avif({ quality: q }).buffer()
33
- default:
34
- return img.jpeg({ quality: q }).buffer()
35
- }
36
- }
37
-
38
- export function transformHash(spec: TransformSpec): string {
39
- return createHash('sha256')
40
- .update(JSON.stringify(spec))
41
- .digest('hex')
42
- .slice(0, 16)
43
- }
44
-
45
- export function parseTransformSpec(
46
- query: Record<string, string>,
47
- ): TransformSpec | null {
48
- const { w, h, fit, format, quality } = query
49
- if (!w && !h && !fit && !format && !quality) return null
50
-
51
- const spec: TransformSpec = {}
52
- if (w) spec.w = Number(w)
53
- if (h) spec.h = Number(h)
54
- if (fit === 'fill' || fit === 'inside') spec.fit = fit
55
- if (
56
- format === 'webp' ||
57
- format === 'jpeg' ||
58
- format === 'png' ||
59
- format === 'avif'
60
- ) {
61
- spec.format = format
62
- }
63
- if (quality) spec.quality = Math.min(100, Math.max(1, Number(quality)))
64
- return spec
65
- }
package/src/testing.ts DELETED
@@ -1,31 +0,0 @@
1
- // src/testing.ts — test utilities for Bunderstack applications.
2
-
3
- export type AuthSessionResolverLike = {
4
- api: {
5
- getSession: (opts: { headers: Headers }) => Promise<unknown>
6
- }
7
- }
8
-
9
- export type BunderstackAppLike = {
10
- auth: unknown
11
- }
12
-
13
- /**
14
- * Mock the auth session resolver on a Bunderstack app instance for unit testing.
15
- */
16
- export function mockAuthSession<
17
- TUser extends { id: string; email: string; name?: string; role?: string },
18
- >(
19
- app: BunderstackAppLike,
20
- resolver: (opts: { headers: Headers }) => Promise<{
21
- user: TUser
22
- session?: { activeOrganizationId?: string | null } | null
23
- } | null>,
24
- ): void {
25
- const auth = app.auth as unknown as AuthSessionResolverLike
26
- if (auth?.api) {
27
- auth.api.getSession = resolver as unknown as (opts: {
28
- headers: Headers
29
- }) => Promise<unknown>
30
- }
31
- }