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/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'
|