@defrex/autobuild-postgres-store 0.8.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 +63 -0
- package/package.json +37 -0
- package/src/auth-schema.ts +238 -0
- package/src/bin.ts +17 -0
- package/src/blobs.test.ts +121 -0
- package/src/config.test.ts +174 -0
- package/src/config.ts +152 -0
- package/src/env.test.ts +48 -0
- package/src/env.ts +31 -0
- package/src/event-wait-poll-ms.test.ts +23 -0
- package/src/index.ts +41 -0
- package/src/migrate.test.ts +640 -0
- package/src/s3.ts +45 -0
- package/src/schema.ts +1099 -0
- package/src/store.live.test.ts +453 -0
- package/src/store.ts +1176 -0
- package/src/testing/concurrent-worker.ts +42 -0
- package/src/tickets.live.test.ts +49 -0
- package/src/tickets.ts +276 -0
- package/src/vercel.ts +53 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { MemoryBlobStore, sampleEventWrite } from '@defrex/autobuild/plugin-sdk'
|
|
2
|
+
import { openPostgresBuildStore } from '../store'
|
|
3
|
+
|
|
4
|
+
async function main(): Promise<void> {
|
|
5
|
+
const [url, mode, resource, countText] = process.argv.slice(2)
|
|
6
|
+
if (!url || !mode || !resource) throw new Error('missing worker arguments')
|
|
7
|
+
const count = Number(countText ?? '1')
|
|
8
|
+
const store = await openPostgresBuildStore(url, new MemoryBlobStore())
|
|
9
|
+
try {
|
|
10
|
+
if (mode === 'build') {
|
|
11
|
+
for (let index = 0; index < count; index++) {
|
|
12
|
+
await store.append(resource, sampleEventWrite(`${process.pid}-${index}`))
|
|
13
|
+
}
|
|
14
|
+
} else if (mode === 'repo') {
|
|
15
|
+
for (let index = 0; index < count; index++) {
|
|
16
|
+
await store.appendRepo(resource, {
|
|
17
|
+
actor: { kind: 'human', user: `worker-${process.pid}` },
|
|
18
|
+
type: 'dispatcher.pause-set',
|
|
19
|
+
payload: { enabled: index % 2 === 0 },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
} else if (mode === 'conditional') {
|
|
23
|
+
const result = await store.appendIfCurrent(
|
|
24
|
+
resource,
|
|
25
|
+
count,
|
|
26
|
+
sampleEventWrite(String(process.pid)),
|
|
27
|
+
)
|
|
28
|
+
console.log(result ? 'winner' : 'stale')
|
|
29
|
+
} else {
|
|
30
|
+
throw new Error(`unknown worker mode ${mode}`)
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
await store.close()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (import.meta.main) {
|
|
38
|
+
main().catch((error: unknown) => {
|
|
39
|
+
console.error(error)
|
|
40
|
+
process.exitCode = 1
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test } from 'bun:test'
|
|
2
|
+
import { SQL } from 'bun'
|
|
3
|
+
import { describeTicketSourceContract } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
+
import { migratePostgres } from './schema'
|
|
5
|
+
import { openPostgresTicketDatabase } from './tickets'
|
|
6
|
+
|
|
7
|
+
const testUrl = process.env.AB_POSTGRES_TEST_URL?.trim()
|
|
8
|
+
|
|
9
|
+
async function isolatedDatabase(): Promise<{ url: string; cleanup(): Promise<void> }> {
|
|
10
|
+
if (!testUrl) throw new Error('AB_POSTGRES_TEST_URL is required')
|
|
11
|
+
const schema = `ab_tickets_${crypto.randomUUID().replaceAll('-', '')}`
|
|
12
|
+
const admin = new SQL(testUrl)
|
|
13
|
+
await admin.unsafe(`CREATE SCHEMA ${schema}`)
|
|
14
|
+
await admin.close()
|
|
15
|
+
const scoped = new URL(testUrl)
|
|
16
|
+
scoped.searchParams.set('options', `-csearch_path=${schema}`)
|
|
17
|
+
await migratePostgres(scoped.toString())
|
|
18
|
+
return {
|
|
19
|
+
url: scoped.toString(),
|
|
20
|
+
cleanup: async () => {
|
|
21
|
+
const sql = new SQL(testUrl)
|
|
22
|
+
try {
|
|
23
|
+
await sql.unsafe(`DROP SCHEMA ${schema} CASCADE`)
|
|
24
|
+
} finally {
|
|
25
|
+
await sql.close()
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!testUrl) {
|
|
32
|
+
describe('PostgreSQL TicketSource live contract', () => {
|
|
33
|
+
test.skip('set AB_POSTGRES_TEST_URL to run TicketSource conformance', () => {})
|
|
34
|
+
})
|
|
35
|
+
} else {
|
|
36
|
+
describeTicketSourceContract('PostgresTicketSource', async () => {
|
|
37
|
+
const isolated = await isolatedDatabase()
|
|
38
|
+
const database = await openPostgresTicketDatabase(isolated.url)
|
|
39
|
+
return {
|
|
40
|
+
source: database.source({ teamKey: 'ENG', claimedState: 'Doing' }),
|
|
41
|
+
states: { ready: 'Ready', claimed: 'Doing', completed: 'Done' },
|
|
42
|
+
editableLabel: 'autobuild',
|
|
43
|
+
cleanup: async () => {
|
|
44
|
+
await database.close()
|
|
45
|
+
await isolated.cleanup()
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
package/src/tickets.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import type {
|
|
3
|
+
DependencyState,
|
|
4
|
+
Ticket,
|
|
5
|
+
TicketCreateOptions,
|
|
6
|
+
TicketDraft,
|
|
7
|
+
TicketListing,
|
|
8
|
+
TicketSource,
|
|
9
|
+
TicketUpdate,
|
|
10
|
+
} from '@defrex/autobuild/plugin-sdk'
|
|
11
|
+
import { validateTicketUpdate } from '@defrex/autobuild/ticket-update'
|
|
12
|
+
import { assertTicketSchema } from './schema'
|
|
13
|
+
|
|
14
|
+
type Row = Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
export interface PostgresTicketLifecycle {
|
|
17
|
+
triage: string
|
|
18
|
+
ready: string
|
|
19
|
+
doing: string
|
|
20
|
+
done: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PostgresTicketContext {
|
|
24
|
+
teamKey: string
|
|
25
|
+
claimedState?: string
|
|
26
|
+
createState?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requiredState(value: string, label: string): string {
|
|
30
|
+
if (!value.trim()) throw new Error(`${label} must be nonblank`)
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function strings(value: unknown): string[] {
|
|
35
|
+
return Array.isArray(value) ? value.map(String) : []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shared database handle; request-specific source views never retain another
|
|
39
|
+
* repository's team or lifecycle overrides. */
|
|
40
|
+
export class PostgresTicketDatabase {
|
|
41
|
+
readonly lifecycle: PostgresTicketLifecycle
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
readonly sql: SQL,
|
|
45
|
+
lifecycle: PostgresTicketLifecycle,
|
|
46
|
+
) {
|
|
47
|
+
this.lifecycle = {
|
|
48
|
+
triage: requiredState(lifecycle.triage, 'triage state'),
|
|
49
|
+
ready: requiredState(lifecycle.ready, 'ready state'),
|
|
50
|
+
doing: requiredState(lifecycle.doing, 'doing state'),
|
|
51
|
+
done: requiredState(lifecycle.done, 'done state'),
|
|
52
|
+
}
|
|
53
|
+
if (new Set(Object.values(this.lifecycle)).size !== 4) {
|
|
54
|
+
throw new Error('database ticket lifecycle states must be distinct')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
source(context: PostgresTicketContext): PostgresTicketSource {
|
|
59
|
+
return new PostgresTicketSource(this.sql, context, this.lifecycle)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
close(): Promise<void> {
|
|
63
|
+
return this.sql.close()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function openPostgresTicketDatabase(
|
|
68
|
+
url: string,
|
|
69
|
+
lifecycle: PostgresTicketLifecycle = {
|
|
70
|
+
triage: 'Triage',
|
|
71
|
+
ready: 'Ready',
|
|
72
|
+
doing: 'Doing',
|
|
73
|
+
done: 'Done',
|
|
74
|
+
},
|
|
75
|
+
): Promise<PostgresTicketDatabase> {
|
|
76
|
+
const sql = new SQL(url)
|
|
77
|
+
try {
|
|
78
|
+
await assertTicketSchema(sql)
|
|
79
|
+
return new PostgresTicketDatabase(sql, lifecycle)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await sql.close()
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class PostgresTicketSource implements TicketSource {
|
|
87
|
+
readonly name = 'database'
|
|
88
|
+
private readonly team: string
|
|
89
|
+
private readonly claimedState: string
|
|
90
|
+
private readonly createState: string
|
|
91
|
+
private readonly allowed: Set<string>
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly sql: SQL,
|
|
95
|
+
context: PostgresTicketContext,
|
|
96
|
+
private readonly lifecycle: PostgresTicketLifecycle,
|
|
97
|
+
) {
|
|
98
|
+
if (!context.teamKey.trim()) throw new Error('ticket teamKey must be nonblank')
|
|
99
|
+
this.team = context.teamKey
|
|
100
|
+
this.allowed = new Set(Object.values(lifecycle))
|
|
101
|
+
this.claimedState = context.claimedState ?? lifecycle.doing
|
|
102
|
+
this.createState = context.createState ?? lifecycle.triage
|
|
103
|
+
this.assertState(this.claimedState)
|
|
104
|
+
this.assertState(this.createState)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private assertState(state: string): void {
|
|
108
|
+
if (!this.allowed.has(state)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`database ticket state "${state}" is not configured; allowed states: ${[...this.allowed].join(', ')}`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async blockers(executor: SQL, id: string): Promise<string[]> {
|
|
116
|
+
const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
|
|
117
|
+
WHERE team = ${this.team} AND ticket_id = ${id} ORDER BY blocker_id`
|
|
118
|
+
return rows.map((row) => String(row.blocker_id))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async record(executor: SQL, row: Row): Promise<Ticket> {
|
|
122
|
+
const id = String(row.id)
|
|
123
|
+
const title = String(row.title)
|
|
124
|
+
const blockedBy = await this.blockers(executor, id)
|
|
125
|
+
return {
|
|
126
|
+
ref: { source: this.name, id, title },
|
|
127
|
+
...(row.creation_key !== null && row.creation_key !== undefined
|
|
128
|
+
? { creationKey: String(row.creation_key) }
|
|
129
|
+
: {}),
|
|
130
|
+
title,
|
|
131
|
+
body: String(row.body),
|
|
132
|
+
state: String(row.state),
|
|
133
|
+
labels: strings(row.labels),
|
|
134
|
+
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async require(executor: SQL, id: string, operation: string, lock = false): Promise<Row> {
|
|
139
|
+
const rows: Row[] = lock
|
|
140
|
+
? await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id} FOR UPDATE`
|
|
141
|
+
: await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
|
|
142
|
+
const row = rows[0]
|
|
143
|
+
if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
|
|
144
|
+
return row
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
|
|
148
|
+
const labels = criteria.labels ?? []
|
|
149
|
+
const rows: Row[] = await this.sql`SELECT * FROM ab_tickets
|
|
150
|
+
WHERE team = ${this.team}
|
|
151
|
+
AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
|
|
152
|
+
AND labels @> ${this.sql.array(labels, 'text')}
|
|
153
|
+
ORDER BY created_at, id`
|
|
154
|
+
return {
|
|
155
|
+
tickets: await Promise.all(rows.map((row) => this.record(this.sql, row))),
|
|
156
|
+
diagnostics: [],
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async get(id: string): Promise<Ticket | null> {
|
|
161
|
+
const rows: Row[] = await this
|
|
162
|
+
.sql`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
|
|
163
|
+
return rows[0] ? this.record(this.sql, rows[0]) : null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async claim(id: string): Promise<boolean> {
|
|
167
|
+
const rows: Row[] = await this.sql`UPDATE ab_tickets SET state = ${this.claimedState},
|
|
168
|
+
updated_at = ${new Date().toISOString()}
|
|
169
|
+
WHERE team = ${this.team} AND id = ${id}
|
|
170
|
+
AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
|
|
171
|
+
return rows.length === 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async comment(id: string, body: string): Promise<void> {
|
|
175
|
+
await this.sql.begin(async (tx) => {
|
|
176
|
+
await this.require(tx, id, 'comment', true)
|
|
177
|
+
const tails: Row[] = await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
|
|
178
|
+
WHERE team = ${this.team} AND ticket_id = ${id}`
|
|
179
|
+
await tx`INSERT INTO ab_ticket_comments (team, ticket_id, seq, body, created_at)
|
|
180
|
+
VALUES (${this.team}, ${id}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async transition(id: string, state: string): Promise<void> {
|
|
185
|
+
this.assertState(state)
|
|
186
|
+
const rows: Row[] = await this.sql`UPDATE ab_tickets SET state = ${state},
|
|
187
|
+
updated_at = ${new Date().toISOString()} WHERE team = ${this.team} AND id = ${id} RETURNING id`
|
|
188
|
+
if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
|
|
192
|
+
if (!draft.title.trim()) throw new Error('database ticket create: title must be nonblank')
|
|
193
|
+
if (!draft.body.trim()) throw new Error('database ticket create: body must be nonblank')
|
|
194
|
+
const state = options.state ?? this.createState
|
|
195
|
+
this.assertState(state)
|
|
196
|
+
return this.sql.begin(async (tx) => {
|
|
197
|
+
if (options.idempotencyKey !== undefined) {
|
|
198
|
+
const adopted: Row[] = await tx`SELECT * FROM ab_tickets
|
|
199
|
+
WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
200
|
+
if (adopted[0]) return this.record(tx, adopted[0])
|
|
201
|
+
}
|
|
202
|
+
for (const blocker of draft.blockedBy ?? []) {
|
|
203
|
+
await this.require(tx, blocker, 'create blocker')
|
|
204
|
+
}
|
|
205
|
+
const id = `ticket-${crypto.randomUUID()}`
|
|
206
|
+
const now = new Date().toISOString()
|
|
207
|
+
const inserted: Row[] = await tx`INSERT INTO ab_tickets
|
|
208
|
+
(team, id, creation_key, title, body, state, labels, created_at, updated_at)
|
|
209
|
+
VALUES (${this.team}, ${id}, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
|
|
210
|
+
${state}, ${tx.array(draft.labels ?? [], 'text')}, ${now}, ${now})
|
|
211
|
+
ON CONFLICT (team, creation_key) DO NOTHING RETURNING *`
|
|
212
|
+
let row = inserted[0]
|
|
213
|
+
if (!row && options.idempotencyKey !== undefined) {
|
|
214
|
+
const adopted: Row[] = await tx`SELECT * FROM ab_tickets
|
|
215
|
+
WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
216
|
+
row = adopted[0]
|
|
217
|
+
}
|
|
218
|
+
if (!row) throw new Error('database ticket create failed')
|
|
219
|
+
if (String(row.id) === id) {
|
|
220
|
+
for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
|
|
221
|
+
if (blocker === id)
|
|
222
|
+
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
223
|
+
await tx`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
|
|
224
|
+
VALUES (${this.team}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return this.record(tx, row)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
232
|
+
const validated = validateTicketUpdate(patch)
|
|
233
|
+
await this.sql.begin(async (tx) => {
|
|
234
|
+
const row = await this.require(tx, id, 'update', true)
|
|
235
|
+
await tx`UPDATE ab_tickets SET
|
|
236
|
+
title = ${validated.title ?? String(row.title)},
|
|
237
|
+
body = ${validated.body ?? String(row.body)},
|
|
238
|
+
labels = ${tx.array(validated.labels ?? strings(row.labels), 'text')},
|
|
239
|
+
updated_at = ${new Date().toISOString()}
|
|
240
|
+
WHERE team = ${this.team} AND id = ${id}`
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async addBlocker(id: string, blockerId: string): Promise<void> {
|
|
245
|
+
await this.sql.begin(async (tx) => {
|
|
246
|
+
await this.require(tx, id, 'addBlocker', true)
|
|
247
|
+
if (id === blockerId)
|
|
248
|
+
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
249
|
+
await this.require(tx, blockerId, 'addBlocker')
|
|
250
|
+
await tx`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
|
|
251
|
+
VALUES (${this.team}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async removeBlocker(id: string, blockerId: string): Promise<void> {
|
|
256
|
+
await this.require(this.sql, id, 'removeBlocker')
|
|
257
|
+
await this.sql`DELETE FROM ab_ticket_blockers
|
|
258
|
+
WHERE team = ${this.team} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async dependencyStates(ids: string[]): Promise<DependencyState[]> {
|
|
262
|
+
return Promise.all(
|
|
263
|
+
ids.map(async (id) => {
|
|
264
|
+
const rows: Row[] = await this.sql`SELECT state FROM ab_tickets
|
|
265
|
+
WHERE team = ${this.team} AND id = ${id}`
|
|
266
|
+
if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
|
|
267
|
+
return {
|
|
268
|
+
id,
|
|
269
|
+
exists: true,
|
|
270
|
+
resolved: String(rows[0].state) === this.lifecycle.done,
|
|
271
|
+
blockedBy: await this.blockers(this.sql, id),
|
|
272
|
+
}
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/vercel.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { get, put, type get as getType, type put as putType } from '@vercel/blob'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import type { BlobStore } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
+
import { blobPath } from './s3'
|
|
5
|
+
|
|
6
|
+
type Put = typeof putType
|
|
7
|
+
type Get = typeof getType
|
|
8
|
+
|
|
9
|
+
export interface VercelBlobStoreOptions {
|
|
10
|
+
access: 'public' | 'private'
|
|
11
|
+
prefix?: string
|
|
12
|
+
token?: string
|
|
13
|
+
oidcToken?: string
|
|
14
|
+
storeId?: string
|
|
15
|
+
put?: Put
|
|
16
|
+
get?: Get
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class VercelBlobStore implements BlobStore {
|
|
20
|
+
private readonly putFn: Put
|
|
21
|
+
private readonly getFn: Get
|
|
22
|
+
|
|
23
|
+
constructor(private readonly options: VercelBlobStoreOptions) {
|
|
24
|
+
this.putFn = options.put ?? put
|
|
25
|
+
this.getFn = options.get ?? get
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private auth(): { token?: string; oidcToken?: string; storeId?: string } {
|
|
29
|
+
return {
|
|
30
|
+
...(this.options.token ? { token: this.options.token } : {}),
|
|
31
|
+
...(this.options.oidcToken ? { oidcToken: this.options.oidcToken } : {}),
|
|
32
|
+
...(this.options.storeId ? { storeId: this.options.storeId } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async put(hash: string, bytes: Uint8Array): Promise<void> {
|
|
37
|
+
await this.putFn(blobPath(hash, this.options.prefix), Buffer.from(bytes), {
|
|
38
|
+
access: this.options.access,
|
|
39
|
+
addRandomSuffix: false,
|
|
40
|
+
allowOverwrite: true,
|
|
41
|
+
...this.auth(),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(hash: string): Promise<Uint8Array | null> {
|
|
46
|
+
const result = await this.getFn(blobPath(hash, this.options.prefix), {
|
|
47
|
+
access: this.options.access,
|
|
48
|
+
...this.auth(),
|
|
49
|
+
})
|
|
50
|
+
if (!result?.stream) return null
|
|
51
|
+
return new Uint8Array(await new Response(result.stream).arrayBuffer())
|
|
52
|
+
}
|
|
53
|
+
}
|