Skip to content

Commit 455946c

Browse files
authored
Merge pull request #6257 from Shopify/jb-multi-env-local-storage
Prevent race conditions during multi environment execution
2 parents 6b3e9e2 + 9966ba6 commit 455946c

File tree

6 files changed

+176
-42
lines changed

6 files changed

+176
-42
lines changed

.changeset/calm-ears-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': minor
3+
---
4+
5+
Ensure commands run in multiple environments use the correct store URL

.changeset/many-ties-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': minor
3+
---
4+
5+
Allow commands run in multiple environments to act on the same store URL

packages/theme/src/cli/services/local-storage.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
removeStorefrontPassword,
1111
ThemeLocalStorageSchema,
1212
setThemeStore,
13+
getThemeStore,
14+
useThemeStoreContext,
1315
} from './local-storage.js'
1416
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
1517
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
@@ -55,4 +57,55 @@ describe('local-storage', () => {
5557
})
5658
})
5759
})
60+
61+
describe('getThemeStore', () => {
62+
test('selects store from context when inside useThemeStoreContext', async () => {
63+
await inTemporaryDirectory(async (cwd) => {
64+
const storage = new LocalStorage<ThemeLocalStorageSchema>({cwd})
65+
setThemeStore('storage-store.myshopify.com', storage)
66+
67+
const initialStore = getThemeStore(storage)
68+
let insideContextStore: string | undefined
69+
70+
await useThemeStoreContext('context-store.myshopify.com', async () => {
71+
insideContextStore = getThemeStore(storage)
72+
})
73+
74+
const outsideContextStore = getThemeStore(storage)
75+
76+
expect(initialStore).toBe('storage-store.myshopify.com')
77+
expect(outsideContextStore).toBe('storage-store.myshopify.com')
78+
expect(insideContextStore).toBe('context-store.myshopify.com')
79+
})
80+
})
81+
82+
test('ensures concurrently run commands maintain their own store value', async () => {
83+
await inTemporaryDirectory(async (cwd) => {
84+
const storage = new LocalStorage<ThemeLocalStorageSchema>({cwd})
85+
setThemeStore('storage-store.myshopify.com', storage)
86+
87+
const results: {[key: string]: string | undefined} = {}
88+
89+
await Promise.all([
90+
useThemeStoreContext('store1.myshopify.com', async () => {
91+
await new Promise((resolve) => setTimeout(resolve, 10))
92+
results.env1 = getThemeStore(storage)
93+
}),
94+
useThemeStoreContext('store2.myshopify.com', async () => {
95+
await new Promise((resolve) => setTimeout(resolve, 5))
96+
results.env2 = getThemeStore(storage)
97+
}),
98+
useThemeStoreContext('store3.myshopify.com', async () => {
99+
results.env3 = getThemeStore(storage)
100+
}),
101+
(results.env4 = getThemeStore(storage)),
102+
])
103+
104+
expect(results.env1).toBe('store1.myshopify.com')
105+
expect(results.env2).toBe('store2.myshopify.com')
106+
expect(results.env3).toBe('store3.myshopify.com')
107+
expect(results.env4).toBe('storage-store.myshopify.com')
108+
})
109+
})
110+
})
58111
})

packages/theme/src/cli/services/local-storage.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {BugError} from '@shopify/cli-kit/node/error'
22
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
33
import {outputDebug, outputContent} from '@shopify/cli-kit/node/output'
4+
import {AsyncLocalStorage} from 'node:async_hooks'
45

56
type DevelopmentThemeId = string
67

@@ -16,6 +17,9 @@ interface ThemeStorePasswordSchema {
1617
[themeStore: string]: string
1718
}
1819

20+
/** Preserves the theme store a command is acting on during multi environment execution */
21+
const themeStoreContext = new AsyncLocalStorage<{store: string}>()
22+
1923
let _themeLocalStorageInstance: LocalStorage<ThemeLocalStorageSchema> | undefined
2024
let _developmentThemeLocalStorageInstance: LocalStorage<DevelopmentThemeLocalStorageSchema> | undefined
2125
let _replThemeLocalStorageInstance: LocalStorage<DevelopmentThemeLocalStorageSchema> | undefined
@@ -56,7 +60,8 @@ function themeStorePasswordStorage() {
5660
}
5761

5862
export function getThemeStore(storage: LocalStorage<ThemeLocalStorageSchema> = themeLocalStorage()) {
59-
return storage.get('themeStore')
63+
const context = themeStoreContext.getStore()
64+
return context?.store ? context.store : storage.get('themeStore')
6065
}
6166

6267
export function setThemeStore(store: string, storage: LocalStorage<ThemeLocalStorageSchema> = themeLocalStorage()) {
@@ -144,3 +149,8 @@ function assertThemeStoreExists(storage: LocalStorage<ThemeLocalStorageSchema> =
144149
}
145150
return themeStore
146151
}
152+
153+
/** Provides theme store context to each environment during concurrent multi environment execution */
154+
export async function useThemeStoreContext<T>(store: string, callback: () => Promise<T>): Promise<T> {
155+
return themeStoreContext.run({store}, callback)
156+
}

packages/theme/src/cli/utilities/theme-command.test.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ describe('ThemeCommand', () => {
135135

136136
test('multiple environments provided - uses renderConcurrent for parallel execution', async () => {
137137
// Given
138-
const environmentConfig = {store: 'store.myshopify.com'}
139-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
138+
vi.mocked(loadEnvironment)
139+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
140+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
140141
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession)
141142

142143
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
@@ -150,7 +151,6 @@ describe('ThemeCommand', () => {
150151
// Then
151152
expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', {from: undefined, silent: true})
152153
expect(loadEnvironment).toHaveBeenCalledWith('staging', 'shopify.theme.toml', {from: undefined, silent: true})
153-
expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(2)
154154

155155
expect(renderConcurrent).toHaveBeenCalledOnce()
156156
expect(renderConcurrent).toHaveBeenCalledWith(
@@ -166,10 +166,40 @@ describe('ThemeCommand', () => {
166166
})
167167

168168
describe('multi environment', () => {
169+
test('commands that act on the same store are run in groups to prevent conflicts', async () => {
170+
// Given
171+
vi.mocked(loadEnvironment)
172+
.mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'wow a theme'})
173+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
174+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'another theme'})
175+
176+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
177+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
178+
179+
await CommandConfig.load()
180+
const command = new TestThemeCommandWithUnionFlags(
181+
['--environment', 'store1-theme', '--environment', 'store1-development', '--environment', 'store2-theme'],
182+
CommandConfig,
183+
)
184+
185+
// When
186+
await command.run()
187+
188+
// Then
189+
const runGroupOneProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes
190+
expect(runGroupOneProcesses).toHaveLength(2)
191+
expect(runGroupOneProcesses?.map((process) => process.prefix)).toEqual(['store1-theme', 'store2-theme'])
192+
193+
const runGroupTwoProcesses = vi.mocked(renderConcurrent).mock.calls[1]?.[0]?.processes
194+
expect(runGroupTwoProcesses).toHaveLength(1)
195+
expect(runGroupTwoProcesses?.map((process) => process.prefix)).toEqual(['store1-development'])
196+
})
197+
169198
test('commands with --force flag should not prompt for confirmation', async () => {
170199
// Given
171-
const environmentConfig = {store: 'store.myshopify.com'}
172-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
200+
vi.mocked(loadEnvironment)
201+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
202+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
173203
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
174204
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
175205

@@ -198,8 +228,9 @@ describe('ThemeCommand', () => {
198228

199229
test('commands that do not allow --force flag should not prompt for confirmation', async () => {
200230
// Given
201-
const environmentConfig = {store: 'store.myshopify.com'}
202-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
231+
vi.mocked(loadEnvironment)
232+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
233+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
203234
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
204235
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
205236

@@ -216,8 +247,9 @@ describe('ThemeCommand', () => {
216247

217248
test('commands without --force flag that allow it should prompt for confirmation', async () => {
218249
// Given
219-
const environmentConfig = {store: 'store.myshopify.com'}
220-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
250+
vi.mocked(loadEnvironment)
251+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
252+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
221253
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
222254
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
223255

@@ -244,8 +276,9 @@ describe('ThemeCommand', () => {
244276

245277
test('should not execute command if confirmation is cancelled', async () => {
246278
// Given
247-
const environmentConfig = {store: 'store.myshopify.com'}
248-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
279+
vi.mocked(loadEnvironment)
280+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
281+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
249282
vi.mocked(renderConfirmationPrompt).mockResolvedValue(false)
250283
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
251284

@@ -340,8 +373,10 @@ describe('ThemeCommand', () => {
340373

341374
test('commands error gracefully and continue with other environments', async () => {
342375
// Given
343-
vi.mocked(loadEnvironment).mockResolvedValue({store: 'store.myshopify.com'})
344-
376+
vi.mocked(loadEnvironment)
377+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
378+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
379+
.mockResolvedValueOnce({store: 'store3.myshopify.com', live: true})
345380
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
346381
vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => {
347382
for (const process of processes) {

packages/theme/src/cli/utilities/theme-command.ts

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {ensureThemeStore} from './theme-store.js'
22
import {configurationFileName} from '../constants.js'
33
import metadata from '../metadata.js'
4+
import {useThemeStoreContext} from '../services/local-storage.js'
45
import {hashString} from '@shopify/cli-kit/node/crypto'
56
import {Input} from '@oclif/core/interfaces'
67
import Command, {ArgOutput, FlagOutput} from '@shopify/cli-kit/node/base-command'
@@ -153,9 +154,8 @@ export default abstract class ThemeCommand extends Command {
153154
environmentMap: Map<EnvironmentName, FlagValues>,
154155
requiredFlags: Exclude<RequiredFlags, null>,
155156
) {
156-
const valid: {environment: EnvironmentName; flags: FlagValues; session: AdminSession}[] = []
157+
const valid: {environment: EnvironmentName; flags: FlagValues}[] = []
157158
const invalid: {environment: EnvironmentName; reason: string}[] = []
158-
const commandName = this.constructor.name.toLowerCase()
159159

160160
for (const [environmentName, environmentFlags] of environmentMap) {
161161
const validationResult = this.validConfig(environmentFlags, requiredFlags, environmentName)
@@ -165,12 +165,7 @@ export default abstract class ThemeCommand extends Command {
165165
continue
166166
}
167167

168-
// eslint-disable-next-line no-await-in-loop
169-
const session = await this.createSession(environmentFlags)
170-
171-
recordEvent(`theme-command:${commandName}:multi-env:authenticated`)
172-
173-
valid.push({environment: environmentName, flags: environmentFlags, session})
168+
valid.push({environment: environmentName, flags: environmentFlags})
174169
}
175170

176171
return {valid, invalid}
@@ -227,30 +222,61 @@ export default abstract class ThemeCommand extends Command {
227222
* Run the command in each valid environment concurrently
228223
* @param validEnvironments - The valid environments to run the command in
229224
*/
230-
private async runConcurrent(
231-
validEnvironments: {environment: EnvironmentName; flags: FlagValues; session: AdminSession}[],
232-
) {
225+
private async runConcurrent(validEnvironments: {environment: EnvironmentName; flags: FlagValues}[]) {
233226
const abortController = new AbortController()
234227

235-
await renderConcurrent({
236-
processes: validEnvironments.map(({environment, flags, session}) => ({
237-
prefix: environment,
238-
action: async (stdout: Writable, stderr: Writable, _signal) => {
239-
try {
240-
await this.command(flags, session, true, {stdout, stderr})
241-
242-
// eslint-disable-next-line no-catch-all/no-catch-all
243-
} catch (error) {
244-
if (error instanceof Error) {
245-
error.message = `Environment ${environment} failed: \n\n${error.message}`
246-
renderError({body: [error.message]})
228+
const stores = validEnvironments.map((env) => env.flags.store as string)
229+
const uniqueStores = new Set(stores)
230+
const runGroups =
231+
stores.length === uniqueStores.size ? [validEnvironments] : this.createSequentialGroups(validEnvironments)
232+
233+
for (const runGroup of runGroups) {
234+
// eslint-disable-next-line no-await-in-loop
235+
await renderConcurrent({
236+
processes: runGroup.map(({environment, flags}) => ({
237+
prefix: environment,
238+
action: async (stdout: Writable, stderr: Writable, _signal) => {
239+
try {
240+
const store = flags.store as string
241+
await useThemeStoreContext(store, async () => {
242+
const session = await this.createSession(flags)
243+
244+
const commandName = this.constructor.name.toLowerCase()
245+
recordEvent(`theme-command:${commandName}:multi-env:authenticated`)
246+
247+
await this.command(flags, session, true, {stdout, stderr})
248+
})
249+
250+
// eslint-disable-next-line no-catch-all/no-catch-all
251+
} catch (error) {
252+
if (error instanceof Error) {
253+
error.message = `Environment ${environment} failed: \n\n${error.message}`
254+
renderError({body: [error.message]})
255+
}
247256
}
248-
}
249-
},
250-
})),
251-
abortSignal: abortController.signal,
252-
showTimestamps: true,
257+
},
258+
})),
259+
abortSignal: abortController.signal,
260+
showTimestamps: true,
261+
})
262+
}
263+
}
264+
265+
/**
266+
* Create groups of environments with unique flags.store values to run sequentially
267+
* to prevent conflicts between environments acting on the same store
268+
* @param environments - The environments to group
269+
* @returns The environment groups
270+
*/
271+
private createSequentialGroups(environments: {environment: EnvironmentName; flags: FlagValues}[]) {
272+
const groups: {environment: EnvironmentName; flags: FlagValues}[][] = []
273+
274+
environments.forEach((environment) => {
275+
const groupWithoutStore = groups.find((arr) => !arr.some((env) => env.flags.store === environment.flags.store))
276+
groupWithoutStore ? groupWithoutStore.push(environment) : groups.push([environment])
253277
})
278+
279+
return groups
254280
}
255281

256282
/**

0 commit comments

Comments
 (0)