Skip to content

Commit 7f54a36

Browse files
committed
Preserve store URL in context with AsyncLocalStorage
Previously, commands that accessed local storage values could potentially receive an incorrect store URL when running multiple environments concurrently due to file overwrites. This commit wraps each command call in a theme store context provider, eliminating the need to grab from the local storage file
1 parent f037172 commit 7f54a36

File tree

4 files changed

+83
-14
lines changed

4 files changed

+83
-14
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

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.ts

Lines changed: 14 additions & 13 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,17 +222,23 @@ 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

235228
await renderConcurrent({
236-
processes: validEnvironments.map(({environment, flags, session}) => ({
229+
processes: validEnvironments.map(({environment, flags}) => ({
237230
prefix: environment,
238231
action: async (stdout: Writable, stderr: Writable, _signal) => {
239232
try {
240-
await this.command(flags, session, true, {stdout, stderr})
233+
const store = flags.store as string
234+
await useThemeStoreContext(store, async () => {
235+
const session = await this.createSession(flags)
236+
237+
const commandName = this.constructor.name.toLowerCase()
238+
recordEvent(`theme-command:${commandName}:multi-env:authenticated`)
239+
240+
await this.command(flags, session, true, {stdout, stderr})
241+
})
241242

242243
// eslint-disable-next-line no-catch-all/no-catch-all
243244
} catch (error) {

0 commit comments

Comments
 (0)