Skip to content

Commit de3295f

Browse files
feat!: add support for conditional writes (#303)
* feat!: add support for conditional writes * chore: fix typo * refactor: rename properties * fix: use right status code
1 parent 06ead41 commit de3295f

File tree

3 files changed

+395
-16
lines changed

3 files changed

+395
-16
lines changed

packages/blobs/src/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import { BlobsInternalError } from './util.ts'
88

99
export const SIGNED_URL_ACCEPT_HEADER = 'application/json;type=signed-url'
1010

11+
export type Conditions = { onlyIfNew?: boolean } | { onlyIfMatch?: string }
12+
1113
interface MakeStoreRequestOptions {
1214
body?: BlobInput | null
15+
conditions?: Conditions
1316
consistency?: ConsistencyMode
1417
headers?: Record<string, string>
1518
key?: string
@@ -172,6 +175,7 @@ export class Client {
172175

173176
async makeRequest({
174177
body,
178+
conditions = {},
175179
consistency,
176180
headers: extraHeaders,
177181
key,
@@ -197,6 +201,12 @@ export class Client {
197201
headers['cache-control'] = 'max-age=0, stale-while-revalidate=60'
198202
}
199203

204+
if ('onlyIfMatch' in conditions && conditions.onlyIfMatch) {
205+
headers['if-match'] = conditions.onlyIfMatch
206+
} else if ('onlyIfNew' in conditions && conditions.onlyIfNew) {
207+
headers['if-none-match'] = '*'
208+
}
209+
200210
const options: RequestInit = {
201211
body,
202212
headers,

packages/blobs/src/main.test.ts

Lines changed: 279 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ describe('get', () => {
132132
siteID,
133133
})
134134

135-
expect(async () => await blobs.get(key)).rejects.toThrowError(
135+
await expect(async () => await blobs.get(key)).rejects.toThrowError(
136136
`Netlify Blobs has generated an internal error (401 status code, ID: ${mockRequestID})`,
137137
)
138138
expect(mockStore.fulfilled).toBeTruthy()
@@ -212,6 +212,126 @@ describe('get', () => {
212212

213213
expect(mockStore.fulfilled).toBeTruthy()
214214
})
215+
216+
describe('Conditional writes', () => {
217+
test('Returns `modified: false` when `onlyIfNew` is true and key exists', async () => {
218+
const mockStore = new MockFetch()
219+
.put({
220+
headers: { authorization: `Bearer ${apiToken}` },
221+
response: new Response(JSON.stringify({ url: signedURL })),
222+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`,
223+
})
224+
.put({
225+
headers: { 'if-none-match': '*' },
226+
response: new Response(null, { status: 412 }),
227+
url: signedURL,
228+
})
229+
.inject()
230+
231+
const blobs = getStore({
232+
name: 'production',
233+
token: apiToken,
234+
siteID,
235+
})
236+
237+
const result = await blobs.set(key, value, {
238+
onlyIfNew: true,
239+
})
240+
241+
expect(result.modified).toBe(false)
242+
expect(result.etag).toBeUndefined()
243+
expect(mockStore.fulfilled).toBeTruthy()
244+
})
245+
246+
test('Returns `modified: true` when `onlyIfNew` is true and key does not exist', async () => {
247+
const mockStore = new MockFetch()
248+
.put({
249+
headers: { authorization: `Bearer ${apiToken}` },
250+
response: new Response(JSON.stringify({ url: signedURL })),
251+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`,
252+
})
253+
.put({
254+
headers: { 'if-none-match': '*' },
255+
response: new Response(null, { status: 201, headers: { etag: '"123"' } }),
256+
url: signedURL,
257+
})
258+
.inject()
259+
260+
const blobs = getStore({
261+
name: 'production',
262+
token: apiToken,
263+
siteID,
264+
})
265+
266+
const result = await blobs.set(key, value, {
267+
onlyIfNew: true,
268+
})
269+
270+
expect(result.modified).toBe(true)
271+
expect(result.etag).toBe('"123"')
272+
expect(mockStore.fulfilled).toBeTruthy()
273+
})
274+
275+
test('Returns `modified: false` when `onlyIfMatch` does not match', async () => {
276+
const etag = 'etag-123'
277+
const mockStore = new MockFetch()
278+
.put({
279+
headers: { authorization: `Bearer ${apiToken}` },
280+
response: new Response(JSON.stringify({ url: signedURL })),
281+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`,
282+
})
283+
.put({
284+
headers: { 'if-match': etag },
285+
response: new Response(null, { status: 412 }),
286+
url: signedURL,
287+
})
288+
.inject()
289+
290+
const blobs = getStore({
291+
name: 'production',
292+
token: apiToken,
293+
siteID,
294+
})
295+
296+
const result = await blobs.set(key, value, {
297+
onlyIfMatch: etag,
298+
})
299+
300+
expect(result.modified).toBe(false)
301+
expect(result.etag).toBeUndefined()
302+
expect(mockStore.fulfilled).toBeTruthy()
303+
})
304+
305+
test('Returns `modified: true` when `onlyIfMatch` matches', async () => {
306+
const etag = 'etag-123'
307+
const mockStore = new MockFetch()
308+
.put({
309+
headers: { authorization: `Bearer ${apiToken}` },
310+
response: new Response(JSON.stringify({ url: signedURL })),
311+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`,
312+
})
313+
.put({
314+
headers: { 'if-match': etag },
315+
response: new Response(null, { status: 200, headers: { etag: '"123"' } }),
316+
url: signedURL,
317+
})
318+
.inject()
319+
320+
const blobs = getStore({
321+
name: 'production',
322+
token: apiToken,
323+
siteID,
324+
})
325+
326+
const result = await blobs.set(key, value, {
327+
onlyIfMatch: etag,
328+
})
329+
330+
expect(result.modified).toBe(true)
331+
expect(result.etag).toBe('"123"')
332+
expect(mockStore.fulfilled).toBeTruthy()
333+
})
334+
})
215335
})
216336

217337
describe('With edge credentials', () => {
@@ -289,6 +409,159 @@ describe('get', () => {
289409
expect(mockStore.fulfilled).toBeTruthy()
290410
})
291411

412+
describe('Conditional writes', () => {
413+
test('Returns `modified: false` when `onlyIfNew` is true and key exists', async () => {
414+
const mockStore = new MockFetch()
415+
.put({
416+
headers: { authorization: `Bearer ${edgeToken}`, 'if-none-match': '*' },
417+
response: new Response(null, { status: 412 }),
418+
url: `${edgeURL}/${siteID}/site:production/${key}`,
419+
})
420+
.inject()
421+
422+
const blobs = getStore({
423+
edgeURL,
424+
name: 'production',
425+
token: edgeToken,
426+
siteID,
427+
})
428+
429+
const result = await blobs.set(key, value, {
430+
onlyIfNew: true,
431+
})
432+
433+
expect(result.modified).toBe(false)
434+
expect(mockStore.fulfilled).toBeTruthy()
435+
})
436+
437+
test('Returns `modified: true` when `onlyIfNew` is true and key does not exist', async () => {
438+
const mockStore = new MockFetch()
439+
.put({
440+
headers: { authorization: `Bearer ${edgeToken}`, 'if-none-match': '*' },
441+
response: new Response(null, { status: 201, headers: { etag: '"123"' } }),
442+
url: `${edgeURL}/${siteID}/site:production/${key}`,
443+
})
444+
.inject()
445+
446+
const blobs = getStore({
447+
edgeURL,
448+
name: 'production',
449+
token: edgeToken,
450+
siteID,
451+
})
452+
453+
const result = await blobs.set(key, value, {
454+
onlyIfNew: true,
455+
})
456+
457+
expect(result.modified).toBe(true)
458+
expect(result.etag).toBe('"123"')
459+
expect(mockStore.fulfilled).toBeTruthy()
460+
})
461+
462+
test('Returns `modified: false` when `onlyIfMatch` does not match', async () => {
463+
const etag = 'etag-123'
464+
const mockStore = new MockFetch()
465+
.put({
466+
headers: { authorization: `Bearer ${edgeToken}`, 'if-match': etag },
467+
response: new Response(null, { status: 412 }),
468+
url: `${edgeURL}/${siteID}/site:production/${key}`,
469+
})
470+
.inject()
471+
472+
const blobs = getStore({
473+
edgeURL,
474+
name: 'production',
475+
token: edgeToken,
476+
siteID,
477+
})
478+
479+
const result = await blobs.set(key, value, {
480+
onlyIfMatch: etag,
481+
})
482+
483+
expect(result.modified).toBe(false)
484+
expect(mockStore.fulfilled).toBeTruthy()
485+
})
486+
487+
test('Returns `modified: true` when `onlyIfMatch` matches', async () => {
488+
const etag = 'etag-123'
489+
const mockStore = new MockFetch()
490+
.put({
491+
headers: { authorization: `Bearer ${edgeToken}`, 'if-match': etag },
492+
response: new Response(null, { status: 200, headers: { etag: '"123"' } }),
493+
url: `${edgeURL}/${siteID}/site:production/${key}`,
494+
})
495+
.inject()
496+
497+
const blobs = getStore({
498+
edgeURL,
499+
name: 'production',
500+
token: edgeToken,
501+
siteID,
502+
})
503+
504+
const result = await blobs.set(key, value, {
505+
onlyIfMatch: etag,
506+
})
507+
508+
expect(result.modified).toBe(true)
509+
expect(result.etag).toBe('"123"')
510+
expect(mockStore.fulfilled).toBeTruthy()
511+
})
512+
513+
test('Throws an error when both `onlyIfNew` and `onlyIfMatch` are provided', async () => {
514+
const blobs = getStore({
515+
name: 'production',
516+
token: apiToken,
517+
siteID,
518+
})
519+
520+
await expect(
521+
blobs.set(key, value, {
522+
onlyIfNew: true,
523+
524+
// @ts-expect-error Testing runtime validation
525+
onlyIfMatch: '"123"',
526+
}),
527+
).rejects.toThrow(
528+
`The 'onlyIfMatch' and 'onlyIfNew' options are mutually exclusive. Using 'onlyIfMatch' will make the write succeed only if there is an entry for the key with the given content, while 'onlyIfNew' will make the write succeed only if there is no entry for the key.`,
529+
)
530+
})
531+
532+
test('Throws an error when `onlyIfMatch` is not a string', async () => {
533+
const blobs = getStore({
534+
name: 'production',
535+
token: apiToken,
536+
siteID,
537+
})
538+
539+
await expect(
540+
blobs.set(key, value, {
541+
// @ts-expect-error Testing runtime validation
542+
onlyIfMatch: 123,
543+
}),
544+
).rejects.toThrow(`The 'onlyIfMatch' property expects a string representing an ETag.`)
545+
})
546+
547+
test('Throws an error when `onlyIfNew` is not a boolean', async () => {
548+
const blobs = getStore({
549+
name: 'production',
550+
token: apiToken,
551+
siteID,
552+
})
553+
554+
await expect(
555+
blobs.set(key, value, {
556+
// @ts-expect-error Testing runtime validation
557+
onlyIfNew: 'yes',
558+
}),
559+
).rejects.toThrow(
560+
`The 'onlyIfNew' property expects a boolean indicating whether the write should fail if an entry for the key already exists.`,
561+
)
562+
})
563+
})
564+
292565
describe('Loads credentials from the environment', () => {
293566
test('From the `NETLIFY_BLOBS_CONTEXT` environment variable', async () => {
294567
const tokens = ['some-token-1', 'another-token-2']
@@ -804,7 +1077,7 @@ describe('set', () => {
8041077
siteID,
8051078
})
8061079

807-
expect(async () => await blobs.set(key, 'value')).rejects.toThrowError(
1080+
await expect(async () => await blobs.set(key, 'value')).rejects.toThrowError(
8081081
`Netlify Blobs has generated an internal error (401 status code)`,
8091082
)
8101083
expect(mockStore.fulfilled).toBeTruthy()
@@ -819,11 +1092,11 @@ describe('set', () => {
8191092
siteID,
8201093
})
8211094

822-
expect(async () => await blobs.set('', 'value')).rejects.toThrowError('Blob key must not be empty.')
823-
expect(async () => await blobs.set('/key', 'value')).rejects.toThrowError(
1095+
await expect(async () => await blobs.set('', 'value')).rejects.toThrowError('Blob key must not be empty.')
1096+
await expect(async () => await blobs.set('/key', 'value')).rejects.toThrowError(
8241097
'Blob key must not start with forward slash (/).',
8251098
)
826-
expect(async () => await blobs.set('a'.repeat(801), 'value')).rejects.toThrowError(
1099+
await expect(async () => await blobs.set('a'.repeat(801), 'value')).rejects.toThrowError(
8271100
'Blob key must be a sequence of Unicode characters whose UTF-8 encoding is at most 600 bytes long.',
8281101
)
8291102
})
@@ -1076,7 +1349,7 @@ describe('setJSON', () => {
10761349
siteID,
10771350
})
10781351

1079-
expect(async () => await blobs.setJSON(key, { value }, { metadata })).rejects.toThrowError(
1352+
await expect(async () => await blobs.setJSON(key, { value }, { metadata })).rejects.toThrowError(
10801353
'Metadata object exceeds the maximum size',
10811354
)
10821355
})

0 commit comments

Comments
 (0)