Skip to content

Commit 8151418

Browse files
authored
@W-20726537 Data access layer middleware (#3648)
1 parent 6e2b841 commit 8151418

File tree

10 files changed

+1468
-180
lines changed

10 files changed

+1468
-180
lines changed

packages/pwa-kit-runtime/package-lock.json

Lines changed: 951 additions & 174 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pwa-kit-runtime/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
},
3232
"dependencies": {
3333
"@aws-sdk/client-cloudwatch": "^3.962.0",
34+
"@aws-sdk/client-dynamodb": "^3.989.0",
35+
"@aws-sdk/lib-dynamodb": "^3.989.0",
3436
"@h4ad/serverless-adapter": "4.4.0",
3537
"@loadable/babel-plugin": "^5.15.3",
3638
"cosmiconfig": "8.1.3",

packages/pwa-kit-runtime/src/utils/ssr-server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// This file is kept for backwards compatibility / simpler imports.
1313
export * from './ssr-server/cached-response'
1414
export * from './ssr-server/configure-proxy'
15+
export * from './ssr-server/data-store'
1516
export * from './ssr-server/metrics-sender'
1617
export * from './ssr-server/outgoing-request-hook'
1718
export * from './ssr-server/parse-end-parameters'
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright (c) 2026, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {logMRTError} from './utils'
8+
9+
export class DataStoreNotFoundError extends Error {
10+
constructor(message) {
11+
super(message)
12+
this.name = 'DataStoreNotFoundError'
13+
Object.setPrototypeOf(this, DataStoreNotFoundError.prototype)
14+
}
15+
}
16+
17+
export class DataStoreServiceError extends Error {
18+
constructor(message) {
19+
super(message)
20+
this.name = 'DataStoreServiceError'
21+
Object.setPrototypeOf(this, DataStoreServiceError.prototype)
22+
}
23+
}
24+
25+
export class DataStoreUnavailableError extends Error {
26+
constructor(message) {
27+
super(message)
28+
this.name = 'DataStoreUnavailableError'
29+
Object.setPrototypeOf(this, DataStoreUnavailableError.prototype)
30+
}
31+
}
32+
33+
/**
34+
* A class for reading entries from the data store.
35+
*
36+
* This class uses a singleton pattern.
37+
* Use DataStore.getDataStore() to get the singleton instance.
38+
*/
39+
export class DataStore {
40+
_tableName = ''
41+
_ddb = null
42+
static _instance = null
43+
44+
/**
45+
* Private constructor for singleton use DataStore.getDataStore() instead.
46+
*
47+
* @private
48+
*/
49+
constructor() {
50+
// Private constructor for singleton use DataStore.getDataStore() instead.
51+
}
52+
53+
/**
54+
* Get or create a DynamoDB document client (for abstraction of attribute values).
55+
*
56+
* @private
57+
* @returns The DynamoDB document client
58+
* @throws {DataStoreUnavailableError} The data store is unavailable
59+
*/
60+
_getClient() {
61+
if (!this.isDataStoreAvailable()) {
62+
throw new DataStoreUnavailableError('The data store is unavailable.')
63+
}
64+
65+
if (!this._ddb) {
66+
// eslint-disable-next-line @typescript-eslint/no-var-requires
67+
const {DynamoDBClient} = require('@aws-sdk/client-dynamodb')
68+
// eslint-disable-next-line @typescript-eslint/no-var-requires
69+
const {DynamoDBDocumentClient} = require('@aws-sdk/lib-dynamodb')
70+
71+
this._tableName = `DataAccessLayer-${process.env.AWS_REGION}`
72+
this._ddb = DynamoDBDocumentClient.from(
73+
new DynamoDBClient({
74+
region: process.env.AWS_REGION
75+
})
76+
)
77+
}
78+
79+
return this._ddb
80+
}
81+
82+
/**
83+
* Get or create the singleton DataStore instance.
84+
*
85+
* @returns The singleton DataStore instance
86+
*/
87+
static getDataStore() {
88+
if (!DataStore._instance) {
89+
DataStore._instance = new DataStore()
90+
}
91+
return DataStore._instance
92+
}
93+
94+
/**
95+
* Whether the data store can be used in the current environment.
96+
*
97+
* @returns true if the data store is available, false otherwise
98+
*/
99+
isDataStoreAvailable() {
100+
return Boolean(
101+
process.env.AWS_REGION && process.env.MOBIFY_PROPERTY_ID && process.env.DEPLOY_TARGET
102+
)
103+
}
104+
105+
/**
106+
* Fetch an entry from the data store.
107+
*
108+
* @param key The data store entry's key
109+
* @returns An object containing the entry's key and value
110+
* @throws {DataStoreUnavailableError} The data store is unavailable
111+
* @throws {DataStoreNotFoundError} An entry with the given key cannot be found
112+
* @throws {DataStoreServiceError} An internal error occurred
113+
*/
114+
async getEntry(key) {
115+
if (!this.isDataStoreAvailable()) {
116+
throw new DataStoreUnavailableError('The data store is unavailable.')
117+
}
118+
119+
const ddb = this._getClient()
120+
// eslint-disable-next-line @typescript-eslint/no-var-requires
121+
const {GetCommand} = require('@aws-sdk/lib-dynamodb')
122+
123+
let response
124+
try {
125+
response = await ddb.send(
126+
new GetCommand({
127+
TableName: this._tableName,
128+
Key: {
129+
projectEnvironment: `${process.env.MOBIFY_PROPERTY_ID} ${process.env.DEPLOY_TARGET}`,
130+
key
131+
}
132+
})
133+
)
134+
} catch (error) {
135+
logMRTError('data_store', error, {key, tableName: this._tableName})
136+
throw new DataStoreServiceError('Data store request failed.')
137+
}
138+
139+
if (!response.Item?.value) {
140+
throw new DataStoreNotFoundError(`Data store entry '${key}' not found.`)
141+
}
142+
143+
return {key, value: response.Item.value}
144+
}
145+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Copyright (c) 2026, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import {
9+
DataStore,
10+
DataStoreNotFoundError,
11+
DataStoreServiceError,
12+
DataStoreUnavailableError
13+
} from './data-store'
14+
15+
jest.mock('./utils', () => ({
16+
logMRTError: jest.fn()
17+
}))
18+
19+
jest.mock('@aws-sdk/client-dynamodb', () => ({
20+
DynamoDBClient: jest.fn()
21+
}))
22+
23+
jest.mock('@aws-sdk/lib-dynamodb', () => ({
24+
DynamoDBDocumentClient: {
25+
from: jest.fn()
26+
},
27+
GetCommand: jest.fn()
28+
}))
29+
30+
import {logMRTError} from './utils'
31+
import {DynamoDBClient} from '@aws-sdk/client-dynamodb'
32+
import {DynamoDBDocumentClient, GetCommand} from '@aws-sdk/lib-dynamodb'
33+
34+
describe('DataStore', () => {
35+
let mockSend
36+
let originalEnv
37+
38+
beforeEach(() => {
39+
// Save original environment variables
40+
originalEnv = {...process.env}
41+
DataStore._instance = null
42+
43+
// Mock DynamoDB client
44+
mockSend = jest.fn()
45+
DynamoDBDocumentClient.from.mockReturnValue({
46+
send: mockSend
47+
})
48+
49+
// Default to the data store being available
50+
process.env.AWS_REGION = 'ca-central-1'
51+
process.env.MOBIFY_PROPERTY_ID = 'my-project'
52+
process.env.DEPLOY_TARGET = 'my-target'
53+
})
54+
55+
afterEach(() => {
56+
// Restore original environment variables
57+
process.env = originalEnv
58+
59+
// Reset singleton
60+
DataStore._instance = null
61+
62+
jest.clearAllMocks()
63+
})
64+
65+
describe('getDataStore', () => {
66+
test('should return singleton instance', () => {
67+
const store1 = DataStore.getDataStore()
68+
const store2 = DataStore.getDataStore()
69+
70+
expect(store1).toBe(store2)
71+
expect(store1).toBeInstanceOf(DataStore)
72+
})
73+
})
74+
75+
describe('isDataStoreAvailable', () => {
76+
test('should return true when all required env vars are set', () => {
77+
const store = DataStore.getDataStore()
78+
expect(store.isDataStoreAvailable()).toBe(true)
79+
})
80+
81+
test.each(['AWS_REGION', 'MOBIFY_PROPERTY_ID', 'DEPLOY_TARGET'])(
82+
'should return false when %s is missing',
83+
(environmentVariableName) => {
84+
delete process.env[environmentVariableName]
85+
86+
const store = DataStore.getDataStore()
87+
88+
expect(store.isDataStoreAvailable()).toBe(false)
89+
}
90+
)
91+
})
92+
93+
describe('getEntry', () => {
94+
test.each(['AWS_REGION', 'MOBIFY_PROPERTY_ID', 'DEPLOY_TARGET'])(
95+
'should throw DataStoreUnavailableError when %s is missing',
96+
async (environmentVariableName) => {
97+
delete process.env[environmentVariableName]
98+
99+
const store = DataStore.getDataStore()
100+
101+
await expect(store.getEntry('my-key')).rejects.toThrow(
102+
new DataStoreUnavailableError('The data store is unavailable.')
103+
)
104+
}
105+
)
106+
107+
test.each([
108+
{Item: {value: {}}},
109+
{Item: {value: {theme: 'dark'}}},
110+
{Item: {value: {nested: {theme: 'light'}}}}
111+
])('should return entry when value exists', async (mockValue) => {
112+
mockSend.mockResolvedValue(mockValue)
113+
114+
const store = DataStore.getDataStore()
115+
const result = await store.getEntry('my-key')
116+
117+
expect(result).toEqual({key: 'my-key', value: mockValue.Item.value})
118+
expect(DynamoDBClient).toHaveBeenCalledWith({region: 'ca-central-1'})
119+
expect(mockSend).toHaveBeenCalledTimes(1)
120+
expect(GetCommand).toHaveBeenCalledWith({
121+
TableName: 'DataAccessLayer-ca-central-1',
122+
Key: {
123+
projectEnvironment: 'my-project my-target',
124+
key: 'my-key'
125+
}
126+
})
127+
})
128+
129+
test.each([
130+
{},
131+
{Item: {}},
132+
{Item: {key: 'my-key'}},
133+
{Item: {value: null}},
134+
{Item: {value: undefined}}
135+
])(
136+
'should throw DataStoreNotFoundError when value not found or is null/undefined',
137+
async (mockValue) => {
138+
mockSend.mockResolvedValue(mockValue)
139+
140+
const store = DataStore.getDataStore()
141+
142+
await expect(store.getEntry('my-key')).rejects.toThrow(
143+
new DataStoreNotFoundError("Data store entry 'my-key' not found.")
144+
)
145+
}
146+
)
147+
148+
test('should throw DataStoreServiceError and log internal error when send throws', async () => {
149+
const dynamoError = new Error('DynamoDB throttled')
150+
mockSend.mockRejectedValue(dynamoError)
151+
152+
const store = DataStore.getDataStore()
153+
154+
await expect(store.getEntry('my-key')).rejects.toThrow(
155+
new DataStoreServiceError('Data store request failed.')
156+
)
157+
expect(logMRTError).toHaveBeenCalledWith('data_store', dynamoError, {
158+
key: 'my-key',
159+
tableName: 'DataAccessLayer-ca-central-1'
160+
})
161+
})
162+
})
163+
})
164+
165+
describe('DataStoreUnavailableError', () => {
166+
test('should have correct name and message', () => {
167+
const err = new DataStoreUnavailableError('the data store is unavailable')
168+
expect(err.name).toBe('DataStoreUnavailableError')
169+
expect(err.message).toBe('the data store is unavailable')
170+
expect(err).toBeInstanceOf(Error)
171+
})
172+
})
173+
174+
describe('DataStoreNotFoundError', () => {
175+
test('should have correct name and message', () => {
176+
const err = new DataStoreNotFoundError('entry not found')
177+
expect(err.name).toBe('DataStoreNotFoundError')
178+
expect(err.message).toBe('entry not found')
179+
expect(err).toBeInstanceOf(Error)
180+
})
181+
})
182+
183+
describe('DataStoreServiceError', () => {
184+
test('should have correct name and message', () => {
185+
const err = new DataStoreServiceError('this request failed')
186+
expect(err.name).toBe('DataStoreServiceError')
187+
expect(err.message).toBe('this request failed')
188+
expect(err).toBeInstanceOf(Error)
189+
})
190+
})

packages/pwa-kit-runtime/src/utils/ssr-server/metrics-sender.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,6 @@ MetricsSender._override = !!process.env.SEND_CW_METRICS
199199
/**
200200
* Get the singleton MetricsSender
201201
*
202-
* @private
203202
* @returns {MetricsSender}
204203
*/
205204
MetricsSender.getSender = () => {

packages/pwa-kit-runtime/src/utils/ssr-server/utils.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,23 @@ export const forEachIn = (iterable, functionRef) => {
166166
functionRef(key, iterable[key])
167167
})
168168
}
169+
170+
/**
171+
* Log an internal MRT error.
172+
*
173+
* @param namespace Namespace for the error (e.g. data_store, redirect) to facilitate searching
174+
* @param err Error to log
175+
* @param context Optional context to include in the log
176+
*/
177+
export const logMRTError = (namespace, err, context) => {
178+
const error = err instanceof Error ? err : new Error(String(err))
179+
console.error(
180+
JSON.stringify({
181+
[`__MRT__${namespace}`]: 'error',
182+
type: 'MRT_internal',
183+
error: error.message,
184+
stack: error.stack,
185+
...context
186+
})
187+
)
188+
}

0 commit comments

Comments
 (0)