Skip to content

feat: add consistency configuration property #143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { BlobsConsistencyError, ConsistencyMode } from './consistency.ts'
import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts'
import { encodeMetadata, Metadata, METADATA_HEADER_EXTERNAL, METADATA_HEADER_INTERNAL } from './metadata.ts'
import { fetchAndRetry } from './retry.ts'
import { BlobInput, Fetcher, HTTPMethod } from './types.ts'

interface MakeStoreRequestOptions {
body?: BlobInput | null
consistency?: ConsistencyMode
headers?: Record<string, string>
key?: string
metadata?: Metadata
Expand All @@ -15,13 +17,16 @@ interface MakeStoreRequestOptions {

export interface ClientOptions {
apiURL?: string
consistency?: ConsistencyMode
edgeURL?: string
fetch?: Fetcher
siteID: string
token: string
uncachedEdgeURL?: string
}

interface GetFinalRequestOptions {
consistency?: ConsistencyMode
key: string | undefined
metadata?: Metadata
method: string
Expand All @@ -31,17 +36,21 @@ interface GetFinalRequestOptions {

export class Client {
private apiURL?: string
private consistency: ConsistencyMode
private edgeURL?: string
private fetch: Fetcher
private siteID: string
private token: string
private uncachedEdgeURL?: string

constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions) {
constructor({ apiURL, consistency, edgeURL, fetch, siteID, token, uncachedEdgeURL }: ClientOptions) {
this.apiURL = apiURL
this.consistency = consistency ?? 'eventual'
this.edgeURL = edgeURL
this.fetch = fetch ?? globalThis.fetch
this.siteID = siteID
this.token = token
this.uncachedEdgeURL = uncachedEdgeURL

if (!this.fetch) {
throw new Error(
Expand All @@ -50,10 +59,22 @@ export class Client {
}
}

private async getFinalRequest({ key, metadata, method, parameters = {}, storeName }: GetFinalRequestOptions) {
private async getFinalRequest({
consistency: opConsistency,
key,
metadata,
method,
parameters = {},
storeName,
}: GetFinalRequestOptions) {
const encodedMetadata = encodeMetadata(metadata)
const consistency = opConsistency ?? this.consistency

if (this.edgeURL) {
if (consistency === 'strong' && !this.uncachedEdgeURL) {
throw new BlobsConsistencyError()
}

const headers: Record<string, string> = {
authorization: `Bearer ${this.token}`,
}
Expand All @@ -63,7 +84,7 @@ export class Client {
}

const path = key ? `/${this.siteID}/${storeName}/${key}` : `/${this.siteID}/${storeName}`
const url = new URL(path, this.edgeURL)
const url = new URL(path, consistency === 'strong' ? this.uncachedEdgeURL : this.edgeURL)

for (const key in parameters) {
url.searchParams.set(key, parameters[key])
Expand Down Expand Up @@ -124,6 +145,7 @@ export class Client {

async makeRequest({
body,
consistency,
headers: extraHeaders,
key,
metadata,
Expand All @@ -132,6 +154,7 @@ export class Client {
storeName,
}: MakeStoreRequestOptions) {
const { headers: baseHeaders = {}, url } = await this.getFinalRequest({
consistency,
key,
metadata,
method,
Expand Down Expand Up @@ -184,10 +207,12 @@ export const getClientOptions = (

const clientOptions = {
apiURL: context.apiURL ?? options.apiURL,
consistency: options.consistency,
edgeURL: context.edgeURL ?? options.edgeURL,
fetch: options.fetch,
siteID,
token,
uncachedEdgeURL: context.uncachedEdgeURL ?? options.uncachedEdgeURL,
}

return clientOptions
Expand Down
11 changes: 11 additions & 0 deletions src/consistency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type ConsistencyMode = 'eventual' | 'strong'

export class BlobsConsistencyError extends Error {
constructor() {
super(
`Netlify Blobs has failed to perform a read using strong consistency because the environment has not been configured with a 'uncachedEdgeURL' property`,
)

this.name = 'BlobsConsistencyError'
}
}
1 change: 1 addition & 0 deletions src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface EnvironmentContext {
edgeURL?: string
siteID?: string
token?: string
uncachedEdgeURL?: string
}

export const getEnvironmentContext = (): EnvironmentContext => {
Expand Down
180 changes: 180 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const apiToken = 'some token'
const signedURL = 'https://signed.url/123456789'
const edgeToken = 'some other token'
const edgeURL = 'https://edge.netlify'
const uncachedEdgeURL = 'https://uncached.edge.netlify'

describe('get', () => {
describe('With API credentials', () => {
Expand Down Expand Up @@ -1493,3 +1494,182 @@ describe(`getStore`, () => {
)
})
})

describe('Consistency configuration', () => {
test('Respects the consistency mode supplied in the operation methods', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})

globalThis.fetch = mockStore.fetch

const context = {
edgeURL,
siteID,
token: edgeToken,
uncachedEdgeURL,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

const blobs = getStore('production')

const data = await blobs.get(key, { consistency: 'strong' })
expect(data).toBe(value)

const meta = await blobs.getMetadata(key, { consistency: 'strong' })
expect(meta?.etag).toBe(headers.etag)
expect(meta?.metadata).toEqual(mockMetadata)

const dataWithMeta = await blobs.getWithMetadata(key, { consistency: 'strong' })
expect(dataWithMeta?.data).toBe(value)
expect(dataWithMeta?.etag).toBe(headers.etag)
expect(dataWithMeta?.metadata).toEqual(mockMetadata)

expect(mockStore.fulfilled).toBeTruthy()
})

test('Respects the consistency mode supplied in the store constructor', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})

globalThis.fetch = mockStore.fetch

const blobs = getStore({
consistency: 'strong',
edgeURL,
name: 'production',
token: edgeToken,
siteID,
uncachedEdgeURL,
})

const data = await blobs.get(key)
expect(data).toBe(value)

const meta = await blobs.getMetadata(key)
expect(meta?.etag).toBe(headers.etag)
expect(meta?.metadata).toEqual(mockMetadata)

const dataWithMeta = await blobs.getWithMetadata(key)
expect(dataWithMeta?.data).toBe(value)
expect(dataWithMeta?.etag).toBe(headers.etag)
expect(dataWithMeta?.metadata).toEqual(mockMetadata)

expect(mockStore.fulfilled).toBeTruthy()
})

test('The consistency mode from the operation methods takes precedence over the store configuration', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${edgeURL}/${siteID}/production/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${edgeURL}/${siteID}/production/${key}`,
})

globalThis.fetch = mockStore.fetch

const blobs = getStore({
consistency: 'strong',
edgeURL,
name: 'production',
token: edgeToken,
siteID,
uncachedEdgeURL,
})

const data = await blobs.get(key)
expect(data).toBe(value)

const meta = await blobs.getMetadata(key, { consistency: 'eventual' })
expect(meta?.etag).toBe(headers.etag)
expect(meta?.metadata).toEqual(mockMetadata)

const dataWithMeta = await blobs.getWithMetadata(key, { consistency: 'eventual' })
expect(dataWithMeta?.data).toBe(value)
expect(dataWithMeta?.etag).toBe(headers.etag)
expect(dataWithMeta?.metadata).toEqual(mockMetadata)

expect(mockStore.fulfilled).toBeTruthy()
})

test('Throws when strong consistency is used and no `uncachedEdgeURL` property has been defined', async () => {
const context = {
edgeURL,
siteID,
token: edgeToken,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

const store = getStore('productin')

await expect(async () => await store.get('my-key', { consistency: 'strong' })).rejects.toThrowError(
"Netlify Blobs has failed to perform a read using strong consistency because the environment has not been configured with a 'uncachedEdgeURL' property",
)
})
})
Loading